Skip to main content

shell_tunnel/fs/
transfer.rs

1//! In-flight upload sessions.
2//!
3//! Bytes land in a staging file and are renamed into place only after the whole
4//! transfer verifies. A partial file therefore never appears at the destination
5//! — a consumer polling that path sees nothing or sees the finished article.
6
7use std::collections::{HashMap, HashSet};
8use std::io::{Seek, SeekFrom, Write};
9use std::path::{Path, PathBuf};
10use std::sync::{Mutex, RwLock};
11use std::time::{Duration, Instant};
12
13use crate::fs::sha256::Hasher;
14use crate::fs::UPLOAD_DIR;
15
16/// Chunk size advertised to clients, and the ceiling a chunk may not exceed.
17///
18/// Four rather than eight MiB: the relay's body ceiling is 8 MiB
19/// (`relay::MAX_BODY`) and a WebSocket frame plus a JSON header ride on top, so
20/// sitting on the ceiling turns a 413 into a one-byte accident. The relay's
21/// 120s request timeout is also tight for 8 MiB over a slow link.
22pub const DEFAULT_CHUNK_SIZE: usize = 4 * 1024 * 1024;
23
24/// Largest value `--fs-chunk-size` may name. At or above the relay's ceiling
25/// every relayed transfer would 413, and the symptom looks like a server bug.
26pub const MAX_CHUNK_SIZE: usize = 8 * 1024 * 1024;
27
28/// How long a session may sit idle before it is swept.
29pub const SESSION_TTL: Duration = Duration::from_secs(3600);
30
31/// Largest number of upload sessions this process holds open at once.
32///
33/// A session holds an open file handle for up to `SESSION_TTL` (an hour). The
34/// only credential `POST /uploads` requires is `fs.write` — so without a cap,
35/// a token scoped to nothing but `fs.write` could open enough sessions to
36/// exhaust the process's file descriptors, and fd exhaustion is process-wide:
37/// it would degrade `exec` and `session` routes too, which that token has no
38/// capability over at all. That makes this a capability-boundary issue, not
39/// merely a disk-quota one, so it belongs in this endpoint rather than being
40/// left to an operator-configured limit nobody has asked for yet.
41///
42/// 128 is a fixed constant rather than a CLI knob: generous enough that no
43/// legitimate concurrent-upload workload should hit it, small enough that the
44/// worst case (128 open file handles) is nowhere near typical per-process fd
45/// limits (1024+ on Linux, comparable on Windows). Not configurable — YAGNI
46/// until an operator actually needs a different number.
47const MAX_CONCURRENT_UPLOADS: usize = 128;
48
49/// Why an upload operation was refused.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum UploadError {
52    /// No such session (unknown id, or already completed/cancelled/expired).
53    NotFound,
54    /// The chunk did not start where the session expects.
55    OffsetMismatch { expected: u64 },
56    /// Another live session already targets this destination.
57    Conflict,
58    /// The chunk exceeds the advertised chunk size.
59    TooLarge,
60    /// This chunk would push the session past the size declared at creation.
61    SizeExceeded,
62    /// Too many sessions are already open (see `MAX_CONCURRENT_UPLOADS`).
63    TooManySessions,
64    /// The assembled bytes do not hash to what was declared.
65    Checksum {
66        expected: String,
67        actual: String,
68        /// The destination the rejected session was headed for. Widened for
69        /// this field for the same reason `UploadStore::cancel`'s return
70        /// type was: an audit event for a terminal upload outcome should be
71        /// able to name its subject. `dest_rel` is not consumed before this
72        /// variant is built — `take_for_complete` only borrows it (for
73        /// `self.release(&dest_rel)`) beforehand — so there was never a
74        /// reason it could not be carried here; the field was simply never
75        /// added.
76        dest_rel: String,
77    },
78    /// The filesystem refused.
79    Io {
80        /// Already-rendered detail (`ToString` of the underlying
81        /// `io::Error`). A `String`, not the `io::Error` itself: `UploadError`
82        /// derives `Clone`/`PartialEq`/`Eq`, neither of which `io::Error`
83        /// implements.
84        detail: String,
85        /// The underlying `io::Error`'s `raw_os_error()`, carried alongside
86        /// `detail` so a caller can tell ENOSPC apart from an unrelated
87        /// failure without a locale-dependent match on the rendered message
88        /// — see `platform::is_out_of_space`, which `upload_error_response`
89        /// (`src/api/fs.rs`) uses this to answer.
90        raw_os_error: Option<i32>,
91    },
92}
93
94impl From<std::io::Error> for UploadError {
95    fn from(e: std::io::Error) -> Self {
96        UploadError::Io {
97            raw_os_error: e.raw_os_error(),
98            detail: e.to_string(),
99        }
100    }
101}
102
103/// A session whose bytes are all in and whose digest has been computed.
104#[derive(Debug, Clone)]
105pub struct FinishedUpload {
106    pub dest_rel: String,
107    pub part_path: PathBuf,
108    pub bytes: u64,
109    pub digest: String,
110    pub expected: String,
111}
112
113/// One in-flight upload.
114struct Session {
115    dest_rel: String,
116    part_path: PathBuf,
117    declared_size: u64,
118    declared_sha256: String,
119    offset: u64,
120    hasher: Hasher,
121    file: std::fs::File,
122    touched: Instant,
123}
124
125/// All in-flight uploads for this process.
126///
127/// State lives in memory only. A restart loses sessions and the client starts
128/// over; persisting them would mean reconstructing a partial hash across
129/// processes, which is a durability feature nobody has asked for yet. The
130/// staging files a restart leaves behind are swept on startup
131/// (`sweep_orphan_parts`).
132///
133/// Invariant: no method ever holds the `sessions` lock and the `claimed`
134/// lock at the same time. `create` takes `claimed` (in its own block, which
135/// closes before anything else runs) and only later, separately, takes
136/// `sessions`; `cancel`, `sweep`, and `take_for_complete` take `sessions`
137/// first and always drop that guard — explicitly, where it is not the last
138/// use in the enclosing statement — before reaching `claimed` through
139/// `release`/`release_destination`. That the two methods' orderings are
140/// opposite (`claimed` before `sessions` in one, `sessions` before `claimed`
141/// in the other) would be a textbook two-lock deadlock *if* either ever held
142/// both at once; because neither does, the orderings never actually nest and
143/// there is nothing to cycle on. Preserving this is what makes `sessions`
144/// vs. `claimed` safe to reason about independently of `append`'s
145/// documented (non-deadlocking) contention with `sweep` — see `append`'s
146/// doc comment for that argument. Breaking this invariant — folding a
147/// `claimed` access inside a still-held `sessions` guard, or vice versa —
148/// would reintroduce a real deadlock that no existing test would catch.
149pub struct UploadStore {
150    sessions: RwLock<HashMap<String, Mutex<Session>>>,
151    /// Destinations currently claimed, so two sessions cannot race to one path.
152    ///
153    /// A set, not a map: no caller has ever read a value out of this (an
154    /// earlier version stored the claiming session's id as the value, but
155    /// nothing looked it up — `sessions` is the source of truth for which
156    /// id owns which destination). Its length also doubles as the
157    /// live-session count for `MAX_CONCURRENT_UPLOADS`: every live session
158    /// claims exactly one destination and every destination is claimed by
159    /// at most one session, so checking `claimed.len()` under `claimed`'s
160    /// own lock is an atomic admission check — two concurrent callers
161    /// cannot both read a count under the cap and then both insert, because
162    /// the check and the insert share one critical section.
163    claimed: Mutex<HashSet<String>>,
164    chunk_size: usize,
165    counter: std::sync::atomic::AtomicU64,
166}
167
168impl UploadStore {
169    pub fn new(chunk_size: usize) -> Self {
170        Self {
171            sessions: RwLock::new(HashMap::new()),
172            claimed: Mutex::new(HashSet::new()),
173            // Upper bound one *less* than `MAX_CHUNK_SIZE`, matching what
174            // `--fs-chunk-size`'s own startup check enforces (`main.rs`
175            // exits for `size >= MAX_CHUNK_SIZE`). Clamping to
176            // `MAX_CHUNK_SIZE` itself (an earlier version did) is not
177            // reachable through the CLI today, but it is worse than
178            // unreachable: it would silently *accept* exactly the value the
179            // CLI's own check exists to refuse, for any future caller that
180            // constructs a store directly rather than through the CLI.
181            chunk_size: chunk_size.clamp(1, MAX_CHUNK_SIZE - 1),
182            counter: std::sync::atomic::AtomicU64::new(0),
183        }
184    }
185
186    /// The chunk size clients are told to use.
187    pub fn chunk_size(&self) -> usize {
188        self.chunk_size
189    }
190
191    /// Where staging files live for an upload landing at `dest_abs`.
192    ///
193    /// Inside a jail that is one directory at the root, as it has always been.
194    /// Machine-wide there is no single place it could be: `complete` publishes
195    /// by `rename`, which is only atomic within a filesystem, so staging has to
196    /// sit on the same one as the destination. Windows makes this unavoidable
197    /// rather than merely preferable — a staging directory on `C:` cannot be
198    /// renamed onto `D:` at all.
199    ///
200    /// Taking the destination's own parent, rather than the volume root, keeps
201    /// that guarantee on Unix too, where a mount point below `/` is a different
202    /// filesystem and `/` is usually not writable by the account running this.
203    ///
204    /// The cost is that machine-wide staging is no longer one enumerable
205    /// directory, which is what `sweep_orphan_parts` needs — see its doc.
206    pub fn staging_dir(root: &crate::fs::FsRoot, dest_abs: &Path) -> PathBuf {
207        match root.jail_path() {
208            Some(jail) => jail.join(UPLOAD_DIR),
209            None => match dest_abs.parent() {
210                Some(parent) => parent.join(UPLOAD_DIR),
211                // A destination with no parent is a filesystem anchor, which
212                // `resolve_for_create` already refuses as a create target.
213                None => PathBuf::from(UPLOAD_DIR),
214            },
215        }
216    }
217
218    /// Open a session for `dest_rel`, which need not exist yet.
219    ///
220    /// Does *not* sweep expired sessions itself, even opportunistically — an
221    /// earlier version did, right here, before anything else ran. That
222    /// silently discarded whatever session the sweep reclaimed: `UploadStore`
223    /// has no `AuditSink` to record with, so a sweep run from inside this
224    /// method structurally cannot leave a trail. The caller
225    /// (`create_upload_blocking`, `src/api/fs.rs`) now sweeps via the
226    /// audit-aware `sweep_expired_uploads` immediately before calling this,
227    /// preserving the ordering the old internal call existed for: reclaim
228    /// stale capacity before the cap check below runs, so a session old
229    /// enough to matter is freed the moment somebody next asks for a new one
230    /// — the same guarantee, just recorded now instead of silent.
231    pub fn create(
232        &self,
233        root: &crate::fs::FsRoot,
234        dest_abs: &Path,
235        dest_rel: String,
236        size: u64,
237        sha256: String,
238    ) -> Result<String, UploadError> {
239        // Claim the destination first: a second session for the same path is a
240        // silent-overwrite race, and last-writer-wins loses data quietly.
241        {
242            let mut claimed = self.claimed.lock().map_err(|_| poisoned())?;
243            if claimed.contains(&dest_rel) {
244                return Err(UploadError::Conflict);
245            }
246            if claimed.len() >= MAX_CONCURRENT_UPLOADS {
247                return Err(UploadError::TooManySessions);
248            }
249            claimed.insert(dest_rel.clone());
250        }
251
252        let staging = Self::staging_dir(root, dest_abs);
253        if let Err(e) = std::fs::create_dir_all(&staging) {
254            self.release(&dest_rel);
255            return Err(e.into());
256        }
257
258        let serial = self
259            .counter
260            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
261        // `{serial:016x}` is fixed-width lowercase hex of a server-generated
262        // counter — never caller input — so `staging.join` below only ever
263        // appends exactly one ordinary, `..`-free, drive-prefix-free
264        // component. The postcondition `delete_file_blocking` needs for a
265        // caller-supplied name (`src/api/fs.rs`) has nothing to check here.
266        let id = format!("up-{serial:016x}");
267        let part_path = staging.join(format!("{id}.part"));
268
269        // `create_new` (`O_EXCL` on Unix, `CREATE_NEW` on Windows), not
270        // `File::create` (`O_CREAT|O_TRUNC`, no `O_EXCL`). Containment is a
271        // property of the moment of resolution, and a session here can live
272        // for up to `SESSION_TTL` — long enough for `part_path`'s final
273        // component to become a symlink pointing outside the root before this
274        // call runs. `File::create` would follow it and write outside the
275        // jail; `create_new` fails `EEXIST` on an existing name instead of
276        // following it, symlink or not. `part_path` above is safe by
277        // construction (server-generated id), so nothing should exist at this
278        // exact name yet — `create_new` is what makes "should" load-bearing
279        // instead of assumed.
280        let file = match std::fs::OpenOptions::new()
281            .write(true)
282            .create_new(true)
283            .open(&part_path)
284        {
285            Ok(file) => file,
286            Err(e) => {
287                self.release(&dest_rel);
288                return Err(e.into());
289            }
290        };
291
292        let session = Session {
293            dest_rel: dest_rel.clone(),
294            part_path,
295            declared_size: size,
296            declared_sha256: sha256,
297            offset: 0,
298            hasher: Hasher::new(),
299            file,
300            touched: Instant::now(),
301        };
302
303        // Matched rather than `?`-chained (an earlier version chained
304        // `.write().map_err(...)?.insert(...)`): a poisoned `sessions` lock
305        // must not leak the claim or the staging file already created above
306        // — `session` is not moved into the map on this path, so its
307        // `part_path` is still reachable to clean up. `sweep`/`cancel` only
308        // ever reclaim a claim through a *live* entry in `sessions`; if this
309        // session never made it into that map, nothing else will ever
310        // release it.
311        let mut sessions = match self.sessions.write() {
312            Ok(sessions) => sessions,
313            Err(_) => {
314                std::fs::remove_file(&session.part_path).ok();
315                self.release(&dest_rel);
316                return Err(poisoned());
317            }
318        };
319        sessions.insert(id.clone(), Mutex::new(session));
320        drop(sessions);
321
322        if let Ok(mut claimed) = self.claimed.lock() {
323            claimed.insert(dest_rel);
324        }
325        Ok(id)
326    }
327
328    /// How many bytes the session has accepted so far.
329    pub fn offset(&self, id: &str) -> Option<u64> {
330        let sessions = self.sessions.read().ok()?;
331        let session = sessions.get(id)?.lock().ok()?;
332        Some(session.offset)
333    }
334
335    /// Append one chunk, returning the offset to send next.
336    ///
337    /// The offset is checked rather than trusted: a retried request that
338    /// already landed would otherwise be written twice and corrupt the hash.
339    ///
340    /// This holds the session's own `Mutex` (and the store-wide `sessions`
341    /// `RwLock` in its shared read mode) for the full duration of the
342    /// `seek`+`write_all` below, which is blocking disk I/O — not merely an
343    /// in-memory update. That means a concurrent `sweep` (which needs
344    /// `sessions`' *write* lock) blocks until this call finishes, and so does
345    /// any other caller of `append`/`take_for_complete`/`cancel` for this same
346    /// session id (nothing else can, since only one request should ever be
347    /// live per session anyway). Concurrent `append` calls for *different*
348    /// sessions are unaffected — `RwLock` read access is shared. Accepted:
349    /// bounded by one write's duration. This is not the only possible
350    /// shape, though: `HashMap<String, Arc<Mutex<Session>>>` would let this
351    /// function clone the `Arc`, drop the `sessions` read guard immediately,
352    /// and hold only the session's own `Mutex` across the write — removing
353    /// the contention with `sweep`/`create` entirely while keeping the same
354    /// per-session serialization (two chunks for the *same* session still
355    /// cannot interleave, since the session `Mutex` alone already prevents
356    /// that). That is a real improvement, tracked separately rather than
357    /// made here: it restructures the state machine's storage at the tail
358    /// of an already-large task, and the contention it would remove is
359    /// bounded and documented, not a correctness gap.
360    ///
361    /// One failure mode this contention analysis does not cover: if a writer
362    /// ever panicked while holding `sessions`' *write* guard (`create`'s
363    /// insert, `take_for_complete`'s or `sweep`'s remove), `std::sync::RwLock`
364    /// poisons permanently — every later `.read()` and `.write()` on it,
365    /// including this method's and `sweep`'s own, then fails identically and
366    /// forever, not "eventually swept once the panic clears." Unreachable
367    /// today, specifically because every write-lock section in this file is a
368    /// plain `HashMap` insert or remove with no disk I/O and nothing else
369    /// that can panic — not because poisoning itself is impossible. That is
370    /// the condition this note depends on, not a permanent property of the
371    /// type: if a future change adds a fallible operation (a write, a
372    /// panicking conversion, anything that can unwind) inside one of those
373    /// three write-lock sections, this analysis no longer holds and the
374    /// unreachability claim needs re-checking against whatever was added.
375    ///
376    /// This says nothing about the per-session `Mutex` acquired below, which
377    /// *is* held across the `seek`+`write_all` disk I/O this doc comment
378    /// itself describes. It is unreachable for the same underlying reason,
379    /// not the same argument: `seek` and `write_all` report failure through
380    /// `Result`, propagated with `?` rather than unwound, so nothing in that
381    /// critical section can panic either.
382    pub fn append(&self, id: &str, offset: u64, bytes: &[u8]) -> Result<u64, UploadError> {
383        if bytes.len() > self.chunk_size {
384            return Err(UploadError::TooLarge);
385        }
386
387        let sessions = self.sessions.read().map_err(|_| poisoned())?;
388        let cell = sessions.get(id).ok_or(UploadError::NotFound)?;
389        let mut session = cell.lock().map_err(|_| poisoned())?;
390
391        if offset != session.offset {
392            return Err(UploadError::OffsetMismatch {
393                expected: session.offset,
394            });
395        }
396
397        // Refused before a single byte is written: without this, a session
398        // can stream arbitrarily far past what it declared, and the mismatch
399        // is only ever caught at `complete` — after every byte has already
400        // hit disk. `checked_add` rather than a plain `+`: `offset` is
401        // caller-supplied (via `Content-Range`) and could in principle be
402        // adversarially close to `u64::MAX`; overflow is treated the same as
403        // exceeding the declared size, not as a wrapped-around pass.
404        let next_offset = offset.checked_add(bytes.len() as u64);
405        if next_offset.map_or(true, |next| next > session.declared_size) {
406            return Err(UploadError::SizeExceeded);
407        }
408
409        session
410            .file
411            .seek(SeekFrom::Start(offset))
412            .map_err(UploadError::from)?;
413        session.file.write_all(bytes).map_err(UploadError::from)?;
414
415        session.hasher.update(bytes);
416        session.offset += bytes.len() as u64;
417        session.touched = Instant::now();
418        Ok(session.offset)
419    }
420
421    /// Finish a session: verify the digest and hand back the staging file.
422    ///
423    /// The session is always removed from `sessions`. The destination's
424    /// *claim*, however, survives a successful call — see
425    /// `release_destination`'s doc comment for why. A failed checksum is
426    /// different: it is terminal (the bytes on disk are known-wrong, and
427    /// leaving them resumable would invite a client to retry into the same
428    /// wrong result), and terminal means no rename will ever follow, so the
429    /// claim is released immediately in that case — there is nothing left for
430    /// a caller to finish acting on.
431    pub fn take_for_complete(&self, id: &str) -> Result<FinishedUpload, UploadError> {
432        let cell = self
433            .sessions
434            .write()
435            .map_err(|_| poisoned())?
436            .remove(id)
437            .ok_or(UploadError::NotFound)?;
438        let session = cell.into_inner().map_err(|_| poisoned())?;
439
440        let Session {
441            dest_rel,
442            part_path,
443            declared_size,
444            declared_sha256,
445            offset,
446            hasher,
447            file,
448            ..
449        } = session;
450        drop(file);
451
452        let digest = hasher.finish();
453        if declared_size != offset || digest != declared_sha256 {
454            std::fs::remove_file(&part_path).ok();
455            self.release(&dest_rel);
456            return Err(UploadError::Checksum {
457                expected: declared_sha256,
458                actual: digest,
459                dest_rel,
460            });
461        }
462
463        // Deliberately not released here — see `release_destination`.
464        Ok(FinishedUpload {
465            dest_rel,
466            part_path,
467            bytes: offset,
468            digest: digest.clone(),
469            expected: declared_sha256,
470        })
471    }
472
473    /// Release a destination's claim once the caller has finished acting on
474    /// the `FinishedUpload` a prior `take_for_complete` handed back — after
475    /// the rename lands, or after giving up on it (whichever the caller's
476    /// last step was).
477    ///
478    /// Not folded into `take_for_complete` itself: an earlier version of this
479    /// function released the claim there, immediately on success — which
480    /// opened a window between "session removed, claim released" and
481    /// "staging file renamed into place" where a second `create` for the same
482    /// `dest_rel` could succeed and start its own rename racing the first's,
483    /// defeating the reason `claimed` exists at all. Keeping the claim alive
484    /// until the caller explicitly releases it closes that window; the caller
485    /// (`complete_upload` in `src/api/fs.rs`) calls this on every exit path
486    /// after `take_for_complete` succeeds, success or failure of the rename
487    /// alike, so the claim is always released exactly once.
488    pub fn release_destination(&self, dest_rel: &str) {
489        self.release(dest_rel);
490    }
491
492    /// Discard a session and its staging file.
493    ///
494    /// Returns the destination and bytes received so far when a session
495    /// existed to cancel — `None` means no such session (unknown, already
496    /// completed, already cancelled, or already expired). Widened from a
497    /// plain `bool` for the same reason `sweep` returns
498    /// `(id, destination, bytes_received)` instead of just dropping what it
499    /// finds: the caller (`cancel_upload` in `src/api/fs.rs`) records a
500    /// terminal audit event, and an event that cannot name which file was
501    /// cancelled answers only "a session ended", not "what happened to this
502    /// file" — the question an audit trail exists to answer.
503    pub fn cancel(&self, id: &str) -> Option<(String, u64)> {
504        let Ok(mut sessions) = self.sessions.write() else {
505            return None;
506        };
507        let cell = sessions.remove(id)?;
508        // Load-bearing, not tidiness: `release` below takes the `claimed`
509        // lock, and `UploadStore`'s struct-level invariant is that `sessions`
510        // and `claimed` are never held at once. Removing this `drop` would
511        // still compile — `sessions` is unused after this point — but would
512        // hold the `sessions` write guard across the `claimed` acquisition,
513        // breaking that invariant silently.
514        drop(sessions);
515        let Ok(session) = cell.into_inner() else {
516            return None;
517        };
518        self.release(&session.dest_rel);
519        std::fs::remove_file(&session.part_path).ok();
520        Some((session.dest_rel, session.offset))
521    }
522
523    /// Drop sessions idle for longer than `ttl`.
524    ///
525    /// Returns `(id, destination, bytes_received)` for each, so the caller can
526    /// record a terminal audit event. A session that begins and never ends
527    /// leaves a trail showing only a beginning, which is not a trail.
528    pub fn sweep(&self, ttl: Duration) -> Vec<(String, String, u64)> {
529        let mut expired = Vec::new();
530        let Ok(sessions) = self.sessions.read() else {
531            return expired;
532        };
533        let stale: Vec<String> = sessions
534            .iter()
535            .filter(|(_, cell)| {
536                cell.lock()
537                    .map(|s| s.touched.elapsed() >= ttl)
538                    .unwrap_or(false)
539            })
540            .map(|(id, _)| id.clone())
541            .collect();
542        // Load-bearing: `std::sync::RwLock` has no upgrade from a read guard
543        // to a write guard, so holding this one into the loop below (which
544        // needs `sessions.write()`) would deadlock this thread against
545        // itself — a different hazard from the `drop` inside the loop below.
546        drop(sessions);
547
548        for id in stale {
549            let Ok(mut sessions) = self.sessions.write() else {
550                break;
551            };
552            let Some(cell) = sessions.remove(&id) else {
553                continue;
554            };
555            // Load-bearing, not tidiness — same reason as `cancel`'s:
556            // `release` below takes `claimed`, and `UploadStore`'s
557            // struct-level invariant is that `sessions` and `claimed` are
558            // never held at once.
559            drop(sessions);
560            if let Ok(session) = cell.into_inner() {
561                self.release(&session.dest_rel);
562                std::fs::remove_file(&session.part_path).ok();
563                expired.push((id, session.dest_rel, session.offset));
564            }
565        }
566        expired
567    }
568
569    fn release(&self, dest_rel: &str) {
570        if let Ok(mut claimed) = self.claimed.lock() {
571            claimed.remove(dest_rel);
572        }
573    }
574}
575
576impl std::fmt::Debug for UploadStore {
577    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
578        f.debug_struct("UploadStore")
579            .field("chunk_size", &self.chunk_size)
580            .finish_non_exhaustive()
581    }
582}
583
584fn poisoned() -> UploadError {
585    UploadError::Io {
586        detail: "internal lock poisoned".to_string(),
587        raw_os_error: None,
588    }
589}
590
591/// Remove staging files left behind by a previous run.
592///
593/// Sessions do not survive a restart, so any `.part` still present is
594/// unreachable — nothing can resume it and nothing will complete it.
595///
596/// Returns `(upload_id, bytes)` for each file removed, so a caller can record
597/// a terminal audit event per orphan — same reason `UploadStore::sweep` and
598/// `cancel` return what they do, rather than dropping what they find. Two
599/// things are recoverable here and one is not: `upload_id` is the filename
600/// stem (`up-{serial:016x}`, never caller input, so parsing it back out is
601/// safe), and `bytes` is the file's size, which always equals what
602/// `append` had written — but the *destination* lived only in the in-memory
603/// `Session` a restart already discarded before this function ever runs, so
604/// there is nothing here to recover it from. See `AuditEvent::with_upload_id`
605/// for how a caller correlates this back to the `upload.start` that does
606/// have it.
607///
608/// An empty `Vec` covers both "nothing to sweep" and "the staging directory
609/// could not be read at all" (most commonly: no upload has ever run against
610/// this root, so it was never created). Not distinguished, same reasoning as
611/// before this was widened: nothing consumes that distinction.
612///
613/// **Machine-wide scope sweeps nothing here, and that is a real gap rather
614/// than an oversight.** With no `--fs-root`, staging follows each destination
615/// to its own directory (see [`UploadStore::staging_dir`]), so the set of
616/// places a `.part` could be left is every directory anyone has ever uploaded
617/// to — not enumerable without walking every drive, which is not a thing a
618/// startup path should do. What covers it instead is
619/// [`sweep_orphan_parts_in`], called against a single destination's staging
620/// directory when an upload next targets it. The practical difference: inside
621/// a jail an orphan is reclaimed at the next restart; machine-wide it is
622/// reclaimed the next time something uploads to the same directory. Both are
623/// invisible to `list`, which refuses the staging directory by name either
624/// way.
625pub fn sweep_orphan_parts(root: &crate::fs::FsRoot) -> Vec<(String, u64)> {
626    let Some(jail) = root.jail_path() else {
627        return Vec::new();
628    };
629    sweep_orphan_parts_in(&jail.join(UPLOAD_DIR))
630}
631
632/// [`sweep_orphan_parts`] against one staging directory, whatever its scope.
633///
634/// Split out so machine-wide uploads have a reclaim path at all: the caller
635/// that knows a destination knows its staging directory, even though no
636/// startup path can enumerate every such directory.
637pub fn sweep_orphan_parts_in(staging: &Path) -> Vec<(String, u64)> {
638    let Ok(entries) = std::fs::read_dir(staging) else {
639        return Vec::new();
640    };
641    let mut removed = Vec::new();
642    for entry in entries.flatten() {
643        let path = entry.path();
644        if path.extension().and_then(|e| e.to_str()) != Some("part") {
645            continue;
646        }
647        // Read before removing: there is no size to report once the file is
648        // gone. `std::fs::metadata(&path)` — a fresh stat — rather than the
649        // cheaper `entry.metadata()`: on Windows, `DirEntry::metadata()`
650        // returns the `WIN32_FIND_DATA` captured by the `read_dir`
651        // enumeration itself, which can under-report the size of a file
652        // still open elsewhere for writing (verified: a session whose
653        // staging file was just appended to and never closed reported `0`
654        // bytes here, on this platform, until this was changed to a fresh
655        // stat). A second, different way the same `DirEntry` API is not what
656        // it appears to be: `list`'s own walk (`src/api/fs.rs`) already notes
657        // that `DirEntry::metadata` is lstat-like there, so a symlink looks
658        // in-root when `metadata` would follow it out — that one is about
659        // *which* file the metadata describes, this one is about *how current*
660        // it is, but both come from trusting the enumeration's cached view
661        // instead of asking the filesystem again. Not reachable in
662        // production here — the whole reason a `.part` file is orphaned is
663        // that the process that held it open is gone — but a test exercising
664        // this without a real restart can still hit it, and the fresh call
665        // costs one extra syscall per file, on a path that runs once at
666        // startup.
667        let bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
668        let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
669            // Not a name this process ever generated (`up-{serial:016x}.part`
670            // is always valid UTF-8) — nothing to correlate an event to, so
671            // the file is removed but not reported.
672            std::fs::remove_file(&path).ok();
673            continue;
674        };
675        let id = id.to_string();
676        if std::fs::remove_file(&path).is_ok() {
677            removed.push((id, bytes));
678        }
679    }
680    removed
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use crate::fs::FsRoot;
687
688    fn store() -> (tempfile::TempDir, FsRoot, UploadStore) {
689        let dir = tempfile::tempdir().expect("tempdir");
690        let root = FsRoot::new(dir.path()).expect("root");
691        let store = UploadStore::new(DEFAULT_CHUNK_SIZE);
692        (dir, root, store)
693    }
694
695    impl UploadStore {
696        /// `create` from a root-relative destination, resolving it the way the
697        /// API layer does.
698        ///
699        /// `create` takes the resolved absolute destination because staging
700        /// has to land on the destination's own filesystem when no `--fs-root`
701        /// narrows the scope (see `staging_dir`). These tests all run against a
702        /// jail, where that resolution is uninteresting — doing it here rather
703        /// than passing some hand-built path keeps them exercising the same
704        /// path the real caller takes.
705        fn create_rel(
706            &self,
707            root: &FsRoot,
708            dest: &str,
709            size: u64,
710            sha256: String,
711        ) -> Result<String, UploadError> {
712            let absolute = root.resolve_for_create(dest).expect("destination resolves");
713            self.create(root, &absolute, dest.to_string(), size, sha256)
714        }
715    }
716
717    /// The staging directory of a jailed root.
718    ///
719    /// `staging_dir` takes a destination because machine-wide scope has to put
720    /// staging on the destination's own filesystem. A jail ignores it, so these
721    /// tests name that explicitly rather than threading a value none of them
722    /// care about through every call.
723    fn staging_of(root: &FsRoot) -> PathBuf {
724        UploadStore::staging_dir(root, Path::new("ignored-when-jailed"))
725    }
726
727    /// SHA-256 of b"hello world".
728    const HELLO_DIGEST: &str = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
729
730    #[test]
731    fn a_session_starts_at_offset_zero() {
732        let (_dir, root, store) = store();
733        let id = store
734            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
735            .expect("create");
736        assert_eq!(store.offset(&id), Some(0));
737    }
738
739    #[test]
740    fn chunks_advance_the_offset() {
741        let (_dir, root, store) = store();
742        let id = store
743            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
744            .expect("create");
745
746        assert_eq!(store.append(&id, 0, b"hello ").expect("first"), 6);
747        assert_eq!(store.append(&id, 6, b"world").expect("second"), 11);
748    }
749
750    #[test]
751    fn a_chunk_at_the_wrong_offset_is_refused_with_the_expected_one() {
752        let (_dir, root, store) = store();
753        let id = store
754            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
755            .expect("create");
756        store.append(&id, 0, b"hello ").expect("first");
757
758        assert_eq!(
759            store.append(&id, 0, b"again"),
760            Err(UploadError::OffsetMismatch { expected: 6 })
761        );
762    }
763
764    #[test]
765    fn two_sessions_may_not_target_the_same_path() {
766        let (_dir, root, store) = store();
767        store
768            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
769            .expect("first");
770        assert_eq!(
771            store.create_rel(&root, "out.bin", 11, HELLO_DIGEST.into()),
772            Err(UploadError::Conflict)
773        );
774    }
775
776    #[test]
777    fn a_matching_checksum_completes() {
778        let (_dir, root, store) = store();
779        let id = store
780            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
781            .expect("create");
782        store.append(&id, 0, b"hello world").expect("append");
783
784        let finished = store.take_for_complete(&id).expect("complete");
785        assert_eq!(finished.bytes, 11);
786        assert_eq!(finished.digest, HELLO_DIGEST);
787        assert_eq!(finished.dest_rel, "out.bin");
788    }
789
790    #[test]
791    fn a_mismatched_checksum_is_refused() {
792        let (_dir, root, store) = store();
793        let wrong = "0".repeat(64);
794        let id = store
795            .create_rel(&root, "out.bin", 11, wrong.clone())
796            .expect("create");
797        store.append(&id, 0, b"hello world").expect("append");
798
799        match store.take_for_complete(&id) {
800            Err(UploadError::Checksum {
801                expected,
802                actual,
803                dest_rel,
804            }) => {
805                assert_eq!(expected, wrong);
806                assert_eq!(actual, HELLO_DIGEST);
807                assert_eq!(dest_rel, "out.bin");
808            }
809            other => panic!("expected a checksum refusal, got {other:?}"),
810        }
811        // The session is gone and the staging file with it.
812        assert_eq!(store.offset(&id), None);
813    }
814
815    #[test]
816    fn a_chunk_above_the_ceiling_is_refused() {
817        let (_dir, root, store) = store();
818        let id = store
819            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
820            .expect("create");
821        let oversized = vec![0_u8; DEFAULT_CHUNK_SIZE + 1];
822        assert_eq!(store.append(&id, 0, &oversized), Err(UploadError::TooLarge));
823    }
824
825    #[test]
826    fn a_chunk_that_would_exceed_the_declared_size_is_refused() {
827        let (_dir, root, store) = store();
828        // Declares 5 bytes; the digest is irrelevant here since the size
829        // check runs at `append` time, well before any checksum comparison.
830        let id = store
831            .create_rel(&root, "out.bin", 5, HELLO_DIGEST.into())
832            .expect("create");
833        assert_eq!(
834            store.append(&id, 0, b"hello world"),
835            Err(UploadError::SizeExceeded)
836        );
837        // Refused before anything was written: the offset must not have moved.
838        assert_eq!(store.offset(&id), Some(0));
839    }
840
841    #[test]
842    fn a_chunk_landing_exactly_on_the_declared_size_is_accepted() {
843        let (_dir, root, store) = store();
844        let id = store
845            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
846            .expect("create");
847        // Exactly 11 bytes against a declared size of 11 — the boundary
848        // `a_chunk_that_would_exceed_the_declared_size_is_refused` does not
849        // cover, and the one `>` (not `>=`) in the check depends on.
850        assert_eq!(store.append(&id, 0, b"hello world").expect("append"), 11);
851    }
852
853    #[test]
854    fn cancelling_removes_the_session_and_frees_the_destination() {
855        let (_dir, root, store) = store();
856        let id = store
857            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
858            .expect("create");
859        store.append(&id, 0, b"hello ").expect("append");
860
861        let (destination, bytes) = store.cancel(&id).expect("session existed");
862        assert_eq!(destination, "out.bin");
863        assert_eq!(bytes, 6);
864        assert_eq!(store.offset(&id), None);
865        // The destination is claimable again.
866        assert!(store
867            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
868            .is_ok());
869    }
870
871    #[test]
872    fn sweeping_drops_sessions_past_their_ttl() {
873        let (_dir, root, store) = store();
874        let id = store
875            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
876            .expect("create");
877
878        assert_eq!(store.sweep(Duration::ZERO).len(), 1);
879        assert_eq!(store.offset(&id), None);
880    }
881
882    /// `create` used to sweep opportunistically (with the real, fixed
883    /// `SESSION_TTL`) before doing anything else. That call is gone — moved
884    /// to the caller, which sweeps through the audit-aware
885    /// `sweep_expired_uploads` instead (`src/api/fs.rs`) — because
886    /// `UploadStore` has no `AuditSink` to record with, so a sweep run from
887    /// inside this method could never leave a trail. This is the regression
888    /// guard for that move: even a session that a zero-TTL sweep would call
889    /// stale must survive an unrelated `create` call untouched, proving
890    /// `create` itself no longer reclaims anything — only an explicit
891    /// `sweep`/`sweep_expired_uploads` call does.
892    #[test]
893    fn create_does_not_sweep_expired_sessions_itself() {
894        let (_dir, root, store) = store();
895        let id = store
896            .create_rel(&root, "old.bin", 11, HELLO_DIGEST.into())
897            .expect("create");
898
899        store
900            .create_rel(&root, "new.bin", 11, HELLO_DIGEST.into())
901            .expect("second create");
902
903        assert_eq!(
904            store.offset(&id),
905            Some(0),
906            "create must not silently reclaim a stale session; only an explicit sweep call may"
907        );
908    }
909
910    /// The strongest test in this module: `create` opens the staging file
911    /// with `create_new`, which must fail (`EEXIST`) rather than follow an
912    /// existing symlink at that exact name. Planted *before* any session
913    /// exists, exploiting that a fresh store's counter starts at 0 — so the
914    /// first session's id, and therefore its staging path, is predictable
915    /// (`up-0000000000000000.part`).
916    ///
917    /// Two assertions, not one: the create must fail, *and* the outside
918    /// target must be untouched. Checking only the error would still pass a
919    /// version that wrote through the link and then failed for an unrelated
920    /// reason afterward.
921    #[test]
922    fn a_pre_existing_symlink_at_the_predicted_staging_path_cannot_be_written_through() {
923        let outer = tempfile::tempdir().expect("outer tempdir");
924        let root_dir = outer.path().join("root");
925        std::fs::create_dir_all(&root_dir).expect("mkdir root");
926        let root = FsRoot::new(&root_dir).expect("root");
927        let store = UploadStore::new(DEFAULT_CHUNK_SIZE);
928
929        let secret = outer.path().join("secret.txt");
930        std::fs::write(&secret, b"outside-secret").expect("write secret");
931
932        let staging = staging_of(&root);
933        std::fs::create_dir_all(&staging).expect("mkdir staging");
934        let predicted = staging.join("up-0000000000000000.part");
935
936        #[cfg(unix)]
937        let linked = std::os::unix::fs::symlink(&secret, &predicted).is_ok();
938        #[cfg(windows)]
939        let linked = std::os::windows::fs::symlink_file(&secret, &predicted).is_ok();
940        #[cfg(not(any(unix, windows)))]
941        let linked = false;
942        if !linked {
943            return; // symlink privilege unavailable on this runner; skip
944        }
945
946        let result = store.create_rel(&root, "app-new.bin", 11, HELLO_DIGEST.into());
947        assert!(
948            matches!(result, Err(UploadError::Io { .. })),
949            "create_new must refuse a pre-existing symlink at the staging path \
950             rather than follow it, got {result:?}"
951        );
952        assert_eq!(
953            std::fs::read(&secret).expect("read secret"),
954            b"outside-secret",
955            "the outside target must be untouched: the open must fail before \
956             any write reaches it"
957        );
958    }
959
960    #[test]
961    fn a_cap_limits_concurrent_sessions_and_releasing_one_frees_a_slot() {
962        let (_dir, root, store) = store();
963        let mut ids = Vec::with_capacity(MAX_CONCURRENT_UPLOADS);
964        for i in 0..MAX_CONCURRENT_UPLOADS {
965            let id = store
966                .create_rel(&root, &format!("f{i}.bin"), 1, HELLO_DIGEST.into())
967                .unwrap_or_else(|e| panic!("session {i} should fit under the cap: {e:?}"));
968            ids.push(id);
969        }
970
971        assert_eq!(
972            store.create_rel(&root, "one-too-many.bin", 1, HELLO_DIGEST.into()),
973            Err(UploadError::TooManySessions)
974        );
975
976        // Freeing one slot makes room for exactly one more.
977        assert!(store.cancel(&ids[0]).is_some());
978        assert!(store
979            .create_rel(&root, "one-too-many.bin", 1, HELLO_DIGEST.into())
980            .is_ok());
981    }
982
983    #[test]
984    fn completing_an_upload_keeps_the_destination_claimed_until_explicitly_released() {
985        let (_dir, root, store) = store();
986        let id = store
987            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
988            .expect("create");
989        store.append(&id, 0, b"hello world").expect("append");
990        let finished = store.take_for_complete(&id).expect("complete");
991
992        // The caller has not renamed the staging file into place yet (has not
993        // called `release_destination`), so the destination must still be
994        // refused to a second session — otherwise two sessions could both be
995        // mid-publication to the same path.
996        assert_eq!(
997            store.create_rel(&root, "out.bin", 11, HELLO_DIGEST.into()),
998            Err(UploadError::Conflict)
999        );
1000
1001        store.release_destination(&finished.dest_rel);
1002
1003        // Now that the caller is done with it, the destination is claimable again.
1004        assert!(store
1005            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1006            .is_ok());
1007    }
1008
1009    #[test]
1010    fn sweep_orphan_parts_removes_leftover_part_files_and_nothing_else() {
1011        let dir = tempfile::tempdir().expect("tempdir");
1012        let root = FsRoot::new(dir.path()).expect("root");
1013        let staging = staging_of(&root);
1014        std::fs::create_dir_all(&staging).expect("mkdir staging");
1015        std::fs::write(staging.join("up-0000000000000000.part"), b"leftover")
1016            .expect("write orphan");
1017        std::fs::write(staging.join("up-0000000000000001.part"), b"leftover2")
1018            .expect("write second orphan");
1019        // Not a `.part` file — proves the extension filter, not "delete
1020        // everything in the directory".
1021        std::fs::write(staging.join("keep.txt"), b"not a part file").expect("write keep");
1022
1023        let mut removed = sweep_orphan_parts(&root);
1024        removed.sort();
1025        assert_eq!(
1026            removed,
1027            vec![
1028                ("up-0000000000000000".to_string(), 8),
1029                ("up-0000000000000001".to_string(), 9),
1030            ],
1031            "each orphan must be reported by its id (the filename stem) and the bytes it held, so a caller can audit it"
1032        );
1033        assert!(!staging.join("up-0000000000000000.part").exists());
1034        assert!(!staging.join("up-0000000000000001.part").exists());
1035        assert!(
1036            staging.join("keep.txt").exists(),
1037            "only .part files are orphans; anything else in staging must survive"
1038        );
1039    }
1040
1041    #[test]
1042    fn a_poisoned_sessions_lock_does_not_leak_the_claim_or_the_staging_file() {
1043        let (_dir, root, store) = store();
1044
1045        // Poison `sessions` by panicking while holding its write guard.
1046        // `catch_unwind` keeps the panic from taking the test process down;
1047        // the guard's `Drop` still runs during the unwind and marks the
1048        // lock poisoned regardless of the panic being caught afterward.
1049        let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1050            let _guard = store.sessions.write().expect("lock not yet poisoned");
1051            panic!("poison it");
1052        }));
1053        assert!(
1054            poisoned.is_err(),
1055            "the closure must have panicked while holding the write guard"
1056        );
1057
1058        let outcome = store.create_rel(&root, "out.bin", 11, HELLO_DIGEST.into());
1059        assert!(
1060            matches!(outcome, Err(UploadError::Io { .. })),
1061            "a poisoned sessions lock must surface as an Io error, got {outcome:?}"
1062        );
1063
1064        // Recover the lock — a real caller cannot do this, but the test does,
1065        // purely to inspect whether the failed attempt above left anything
1066        // behind. If it did, this second `create` for the same destination
1067        // would come back `Err(Conflict)` instead of succeeding.
1068        store.sessions.clear_poison();
1069        assert!(
1070            store
1071                .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1072                .is_ok(),
1073            "the destination must not still be claimed by the failed attempt"
1074        );
1075
1076        let staging = staging_of(&root);
1077        let leftover_parts = std::fs::read_dir(&staging)
1078            .expect("staging dir")
1079            .flatten()
1080            .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("part"))
1081            .count();
1082        assert_eq!(
1083            leftover_parts, 1,
1084            "only the second, successful session's staging file should remain"
1085        );
1086    }
1087}