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::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 `root`.
192    pub fn staging_dir(root: &crate::fs::FsRoot) -> PathBuf {
193        root.path().join(UPLOAD_DIR)
194    }
195
196    /// Open a session for `dest_rel`, which need not exist yet.
197    ///
198    /// Does *not* sweep expired sessions itself, even opportunistically — an
199    /// earlier version did, right here, before anything else ran. That
200    /// silently discarded whatever session the sweep reclaimed: `UploadStore`
201    /// has no `AuditSink` to record with, so a sweep run from inside this
202    /// method structurally cannot leave a trail. The caller
203    /// (`create_upload_blocking`, `src/api/fs.rs`) now sweeps via the
204    /// audit-aware `sweep_expired_uploads` immediately before calling this,
205    /// preserving the ordering the old internal call existed for: reclaim
206    /// stale capacity before the cap check below runs, so a session old
207    /// enough to matter is freed the moment somebody next asks for a new one
208    /// — the same guarantee, just recorded now instead of silent.
209    pub fn create(
210        &self,
211        root: &crate::fs::FsRoot,
212        dest_rel: String,
213        size: u64,
214        sha256: String,
215    ) -> Result<String, UploadError> {
216        // Claim the destination first: a second session for the same path is a
217        // silent-overwrite race, and last-writer-wins loses data quietly.
218        {
219            let mut claimed = self.claimed.lock().map_err(|_| poisoned())?;
220            if claimed.contains(&dest_rel) {
221                return Err(UploadError::Conflict);
222            }
223            if claimed.len() >= MAX_CONCURRENT_UPLOADS {
224                return Err(UploadError::TooManySessions);
225            }
226            claimed.insert(dest_rel.clone());
227        }
228
229        let staging = Self::staging_dir(root);
230        if let Err(e) = std::fs::create_dir_all(&staging) {
231            self.release(&dest_rel);
232            return Err(e.into());
233        }
234
235        let serial = self
236            .counter
237            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
238        // `{serial:016x}` is fixed-width lowercase hex of a server-generated
239        // counter — never caller input — so `staging.join` below only ever
240        // appends exactly one ordinary, `..`-free, drive-prefix-free
241        // component. The postcondition `delete_file_blocking` needs for a
242        // caller-supplied name (`src/api/fs.rs`) has nothing to check here.
243        let id = format!("up-{serial:016x}");
244        let part_path = staging.join(format!("{id}.part"));
245
246        // `create_new` (`O_EXCL` on Unix, `CREATE_NEW` on Windows), not
247        // `File::create` (`O_CREAT|O_TRUNC`, no `O_EXCL`). Containment is a
248        // property of the moment of resolution, and a session here can live
249        // for up to `SESSION_TTL` — long enough for `part_path`'s final
250        // component to become a symlink pointing outside the root before this
251        // call runs. `File::create` would follow it and write outside the
252        // jail; `create_new` fails `EEXIST` on an existing name instead of
253        // following it, symlink or not. `part_path` above is safe by
254        // construction (server-generated id), so nothing should exist at this
255        // exact name yet — `create_new` is what makes "should" load-bearing
256        // instead of assumed.
257        let file = match std::fs::OpenOptions::new()
258            .write(true)
259            .create_new(true)
260            .open(&part_path)
261        {
262            Ok(file) => file,
263            Err(e) => {
264                self.release(&dest_rel);
265                return Err(e.into());
266            }
267        };
268
269        let session = Session {
270            dest_rel: dest_rel.clone(),
271            part_path,
272            declared_size: size,
273            declared_sha256: sha256,
274            offset: 0,
275            hasher: Hasher::new(),
276            file,
277            touched: Instant::now(),
278        };
279
280        // Matched rather than `?`-chained (an earlier version chained
281        // `.write().map_err(...)?.insert(...)`): a poisoned `sessions` lock
282        // must not leak the claim or the staging file already created above
283        // — `session` is not moved into the map on this path, so its
284        // `part_path` is still reachable to clean up. `sweep`/`cancel` only
285        // ever reclaim a claim through a *live* entry in `sessions`; if this
286        // session never made it into that map, nothing else will ever
287        // release it.
288        let mut sessions = match self.sessions.write() {
289            Ok(sessions) => sessions,
290            Err(_) => {
291                std::fs::remove_file(&session.part_path).ok();
292                self.release(&dest_rel);
293                return Err(poisoned());
294            }
295        };
296        sessions.insert(id.clone(), Mutex::new(session));
297        drop(sessions);
298
299        if let Ok(mut claimed) = self.claimed.lock() {
300            claimed.insert(dest_rel);
301        }
302        Ok(id)
303    }
304
305    /// How many bytes the session has accepted so far.
306    pub fn offset(&self, id: &str) -> Option<u64> {
307        let sessions = self.sessions.read().ok()?;
308        let session = sessions.get(id)?.lock().ok()?;
309        Some(session.offset)
310    }
311
312    /// Append one chunk, returning the offset to send next.
313    ///
314    /// The offset is checked rather than trusted: a retried request that
315    /// already landed would otherwise be written twice and corrupt the hash.
316    ///
317    /// This holds the session's own `Mutex` (and the store-wide `sessions`
318    /// `RwLock` in its shared read mode) for the full duration of the
319    /// `seek`+`write_all` below, which is blocking disk I/O — not merely an
320    /// in-memory update. That means a concurrent `sweep` (which needs
321    /// `sessions`' *write* lock) blocks until this call finishes, and so does
322    /// any other caller of `append`/`take_for_complete`/`cancel` for this same
323    /// session id (nothing else can, since only one request should ever be
324    /// live per session anyway). Concurrent `append` calls for *different*
325    /// sessions are unaffected — `RwLock` read access is shared. Accepted:
326    /// bounded by one write's duration. This is not the only possible
327    /// shape, though: `HashMap<String, Arc<Mutex<Session>>>` would let this
328    /// function clone the `Arc`, drop the `sessions` read guard immediately,
329    /// and hold only the session's own `Mutex` across the write — removing
330    /// the contention with `sweep`/`create` entirely while keeping the same
331    /// per-session serialization (two chunks for the *same* session still
332    /// cannot interleave, since the session `Mutex` alone already prevents
333    /// that). That is a real improvement, tracked separately rather than
334    /// made here: it restructures the state machine's storage at the tail
335    /// of an already-large task, and the contention it would remove is
336    /// bounded and documented, not a correctness gap.
337    ///
338    /// One failure mode this contention analysis does not cover: if a writer
339    /// ever panicked while holding `sessions`' *write* guard (`create`'s
340    /// insert, `take_for_complete`'s or `sweep`'s remove), `std::sync::RwLock`
341    /// poisons permanently — every later `.read()` and `.write()` on it,
342    /// including this method's and `sweep`'s own, then fails identically and
343    /// forever, not "eventually swept once the panic clears." Unreachable
344    /// today, specifically because every write-lock section in this file is a
345    /// plain `HashMap` insert or remove with no disk I/O and nothing else
346    /// that can panic — not because poisoning itself is impossible. That is
347    /// the condition this note depends on, not a permanent property of the
348    /// type: if a future change adds a fallible operation (a write, a
349    /// panicking conversion, anything that can unwind) inside one of those
350    /// three write-lock sections, this analysis no longer holds and the
351    /// unreachability claim needs re-checking against whatever was added.
352    ///
353    /// This says nothing about the per-session `Mutex` acquired below, which
354    /// *is* held across the `seek`+`write_all` disk I/O this doc comment
355    /// itself describes. It is unreachable for the same underlying reason,
356    /// not the same argument: `seek` and `write_all` report failure through
357    /// `Result`, propagated with `?` rather than unwound, so nothing in that
358    /// critical section can panic either.
359    pub fn append(&self, id: &str, offset: u64, bytes: &[u8]) -> Result<u64, UploadError> {
360        if bytes.len() > self.chunk_size {
361            return Err(UploadError::TooLarge);
362        }
363
364        let sessions = self.sessions.read().map_err(|_| poisoned())?;
365        let cell = sessions.get(id).ok_or(UploadError::NotFound)?;
366        let mut session = cell.lock().map_err(|_| poisoned())?;
367
368        if offset != session.offset {
369            return Err(UploadError::OffsetMismatch {
370                expected: session.offset,
371            });
372        }
373
374        // Refused before a single byte is written: without this, a session
375        // can stream arbitrarily far past what it declared, and the mismatch
376        // is only ever caught at `complete` — after every byte has already
377        // hit disk. `checked_add` rather than a plain `+`: `offset` is
378        // caller-supplied (via `Content-Range`) and could in principle be
379        // adversarially close to `u64::MAX`; overflow is treated the same as
380        // exceeding the declared size, not as a wrapped-around pass.
381        let next_offset = offset.checked_add(bytes.len() as u64);
382        if next_offset.map_or(true, |next| next > session.declared_size) {
383            return Err(UploadError::SizeExceeded);
384        }
385
386        session
387            .file
388            .seek(SeekFrom::Start(offset))
389            .map_err(UploadError::from)?;
390        session.file.write_all(bytes).map_err(UploadError::from)?;
391
392        session.hasher.update(bytes);
393        session.offset += bytes.len() as u64;
394        session.touched = Instant::now();
395        Ok(session.offset)
396    }
397
398    /// Finish a session: verify the digest and hand back the staging file.
399    ///
400    /// The session is always removed from `sessions`. The destination's
401    /// *claim*, however, survives a successful call — see
402    /// `release_destination`'s doc comment for why. A failed checksum is
403    /// different: it is terminal (the bytes on disk are known-wrong, and
404    /// leaving them resumable would invite a client to retry into the same
405    /// wrong result), and terminal means no rename will ever follow, so the
406    /// claim is released immediately in that case — there is nothing left for
407    /// a caller to finish acting on.
408    pub fn take_for_complete(&self, id: &str) -> Result<FinishedUpload, UploadError> {
409        let cell = self
410            .sessions
411            .write()
412            .map_err(|_| poisoned())?
413            .remove(id)
414            .ok_or(UploadError::NotFound)?;
415        let session = cell.into_inner().map_err(|_| poisoned())?;
416
417        let Session {
418            dest_rel,
419            part_path,
420            declared_size,
421            declared_sha256,
422            offset,
423            hasher,
424            file,
425            ..
426        } = session;
427        drop(file);
428
429        let digest = hasher.finish();
430        if declared_size != offset || digest != declared_sha256 {
431            std::fs::remove_file(&part_path).ok();
432            self.release(&dest_rel);
433            return Err(UploadError::Checksum {
434                expected: declared_sha256,
435                actual: digest,
436                dest_rel,
437            });
438        }
439
440        // Deliberately not released here — see `release_destination`.
441        Ok(FinishedUpload {
442            dest_rel,
443            part_path,
444            bytes: offset,
445            digest: digest.clone(),
446            expected: declared_sha256,
447        })
448    }
449
450    /// Release a destination's claim once the caller has finished acting on
451    /// the `FinishedUpload` a prior `take_for_complete` handed back — after
452    /// the rename lands, or after giving up on it (whichever the caller's
453    /// last step was).
454    ///
455    /// Not folded into `take_for_complete` itself: an earlier version of this
456    /// function released the claim there, immediately on success — which
457    /// opened a window between "session removed, claim released" and
458    /// "staging file renamed into place" where a second `create` for the same
459    /// `dest_rel` could succeed and start its own rename racing the first's,
460    /// defeating the reason `claimed` exists at all. Keeping the claim alive
461    /// until the caller explicitly releases it closes that window; the caller
462    /// (`complete_upload` in `src/api/fs.rs`) calls this on every exit path
463    /// after `take_for_complete` succeeds, success or failure of the rename
464    /// alike, so the claim is always released exactly once.
465    pub fn release_destination(&self, dest_rel: &str) {
466        self.release(dest_rel);
467    }
468
469    /// Discard a session and its staging file.
470    ///
471    /// Returns the destination and bytes received so far when a session
472    /// existed to cancel — `None` means no such session (unknown, already
473    /// completed, already cancelled, or already expired). Widened from a
474    /// plain `bool` for the same reason `sweep` returns
475    /// `(id, destination, bytes_received)` instead of just dropping what it
476    /// finds: the caller (`cancel_upload` in `src/api/fs.rs`) records a
477    /// terminal audit event, and an event that cannot name which file was
478    /// cancelled answers only "a session ended", not "what happened to this
479    /// file" — the question an audit trail exists to answer.
480    pub fn cancel(&self, id: &str) -> Option<(String, u64)> {
481        let Ok(mut sessions) = self.sessions.write() else {
482            return None;
483        };
484        let cell = sessions.remove(id)?;
485        // Load-bearing, not tidiness: `release` below takes the `claimed`
486        // lock, and `UploadStore`'s struct-level invariant is that `sessions`
487        // and `claimed` are never held at once. Removing this `drop` would
488        // still compile — `sessions` is unused after this point — but would
489        // hold the `sessions` write guard across the `claimed` acquisition,
490        // breaking that invariant silently.
491        drop(sessions);
492        let Ok(session) = cell.into_inner() else {
493            return None;
494        };
495        self.release(&session.dest_rel);
496        std::fs::remove_file(&session.part_path).ok();
497        Some((session.dest_rel, session.offset))
498    }
499
500    /// Drop sessions idle for longer than `ttl`.
501    ///
502    /// Returns `(id, destination, bytes_received)` for each, so the caller can
503    /// record a terminal audit event. A session that begins and never ends
504    /// leaves a trail showing only a beginning, which is not a trail.
505    pub fn sweep(&self, ttl: Duration) -> Vec<(String, String, u64)> {
506        let mut expired = Vec::new();
507        let Ok(sessions) = self.sessions.read() else {
508            return expired;
509        };
510        let stale: Vec<String> = sessions
511            .iter()
512            .filter(|(_, cell)| {
513                cell.lock()
514                    .map(|s| s.touched.elapsed() >= ttl)
515                    .unwrap_or(false)
516            })
517            .map(|(id, _)| id.clone())
518            .collect();
519        // Load-bearing: `std::sync::RwLock` has no upgrade from a read guard
520        // to a write guard, so holding this one into the loop below (which
521        // needs `sessions.write()`) would deadlock this thread against
522        // itself — a different hazard from the `drop` inside the loop below.
523        drop(sessions);
524
525        for id in stale {
526            let Ok(mut sessions) = self.sessions.write() else {
527                break;
528            };
529            let Some(cell) = sessions.remove(&id) else {
530                continue;
531            };
532            // Load-bearing, not tidiness — same reason as `cancel`'s:
533            // `release` below takes `claimed`, and `UploadStore`'s
534            // struct-level invariant is that `sessions` and `claimed` are
535            // never held at once.
536            drop(sessions);
537            if let Ok(session) = cell.into_inner() {
538                self.release(&session.dest_rel);
539                std::fs::remove_file(&session.part_path).ok();
540                expired.push((id, session.dest_rel, session.offset));
541            }
542        }
543        expired
544    }
545
546    fn release(&self, dest_rel: &str) {
547        if let Ok(mut claimed) = self.claimed.lock() {
548            claimed.remove(dest_rel);
549        }
550    }
551}
552
553impl std::fmt::Debug for UploadStore {
554    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
555        f.debug_struct("UploadStore")
556            .field("chunk_size", &self.chunk_size)
557            .finish_non_exhaustive()
558    }
559}
560
561fn poisoned() -> UploadError {
562    UploadError::Io {
563        detail: "internal lock poisoned".to_string(),
564        raw_os_error: None,
565    }
566}
567
568/// Remove staging files left behind by a previous run.
569///
570/// Sessions do not survive a restart, so any `.part` still present is
571/// unreachable — nothing can resume it and nothing will complete it.
572///
573/// Returns `(upload_id, bytes)` for each file removed, so a caller can record
574/// a terminal audit event per orphan — same reason `UploadStore::sweep` and
575/// `cancel` return what they do, rather than dropping what they find. Two
576/// things are recoverable here and one is not: `upload_id` is the filename
577/// stem (`up-{serial:016x}`, never caller input, so parsing it back out is
578/// safe), and `bytes` is the file's size, which always equals what
579/// `append` had written — but the *destination* lived only in the in-memory
580/// `Session` a restart already discarded before this function ever runs, so
581/// there is nothing here to recover it from. See `AuditEvent::with_upload_id`
582/// for how a caller correlates this back to the `upload.start` that does
583/// have it.
584///
585/// An empty `Vec` covers both "nothing to sweep" and "the staging directory
586/// could not be read at all" (most commonly: no upload has ever run against
587/// this root, so it was never created). Not distinguished, same reasoning as
588/// before this was widened: nothing consumes that distinction.
589pub fn sweep_orphan_parts(root: &crate::fs::FsRoot) -> Vec<(String, u64)> {
590    let staging = UploadStore::staging_dir(root);
591    let Ok(entries) = std::fs::read_dir(&staging) else {
592        return Vec::new();
593    };
594    let mut removed = Vec::new();
595    for entry in entries.flatten() {
596        let path = entry.path();
597        if path.extension().and_then(|e| e.to_str()) != Some("part") {
598            continue;
599        }
600        // Read before removing: there is no size to report once the file is
601        // gone. `std::fs::metadata(&path)` — a fresh stat — rather than the
602        // cheaper `entry.metadata()`: on Windows, `DirEntry::metadata()`
603        // returns the `WIN32_FIND_DATA` captured by the `read_dir`
604        // enumeration itself, which can under-report the size of a file
605        // still open elsewhere for writing (verified: a session whose
606        // staging file was just appended to and never closed reported `0`
607        // bytes here, on this platform, until this was changed to a fresh
608        // stat). A second, different way the same `DirEntry` API is not what
609        // it appears to be: `list`'s own walk (`src/api/fs.rs`) already notes
610        // that `DirEntry::metadata` is lstat-like there, so a symlink looks
611        // in-root when `metadata` would follow it out — that one is about
612        // *which* file the metadata describes, this one is about *how current*
613        // it is, but both come from trusting the enumeration's cached view
614        // instead of asking the filesystem again. Not reachable in
615        // production here — the whole reason a `.part` file is orphaned is
616        // that the process that held it open is gone — but a test exercising
617        // this without a real restart can still hit it, and the fresh call
618        // costs one extra syscall per file, on a path that runs once at
619        // startup.
620        let bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
621        let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
622            // Not a name this process ever generated (`up-{serial:016x}.part`
623            // is always valid UTF-8) — nothing to correlate an event to, so
624            // the file is removed but not reported.
625            std::fs::remove_file(&path).ok();
626            continue;
627        };
628        let id = id.to_string();
629        if std::fs::remove_file(&path).is_ok() {
630            removed.push((id, bytes));
631        }
632    }
633    removed
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use crate::fs::FsRoot;
640
641    fn store() -> (tempfile::TempDir, FsRoot, UploadStore) {
642        let dir = tempfile::tempdir().expect("tempdir");
643        let root = FsRoot::new(dir.path()).expect("root");
644        let store = UploadStore::new(DEFAULT_CHUNK_SIZE);
645        (dir, root, store)
646    }
647
648    /// SHA-256 of b"hello world".
649    const HELLO_DIGEST: &str = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
650
651    #[test]
652    fn a_session_starts_at_offset_zero() {
653        let (_dir, root, store) = store();
654        let id = store
655            .create(&root, "out.bin".into(), 11, HELLO_DIGEST.into())
656            .expect("create");
657        assert_eq!(store.offset(&id), Some(0));
658    }
659
660    #[test]
661    fn chunks_advance_the_offset() {
662        let (_dir, root, store) = store();
663        let id = store
664            .create(&root, "out.bin".into(), 11, HELLO_DIGEST.into())
665            .expect("create");
666
667        assert_eq!(store.append(&id, 0, b"hello ").expect("first"), 6);
668        assert_eq!(store.append(&id, 6, b"world").expect("second"), 11);
669    }
670
671    #[test]
672    fn a_chunk_at_the_wrong_offset_is_refused_with_the_expected_one() {
673        let (_dir, root, store) = store();
674        let id = store
675            .create(&root, "out.bin".into(), 11, HELLO_DIGEST.into())
676            .expect("create");
677        store.append(&id, 0, b"hello ").expect("first");
678
679        assert_eq!(
680            store.append(&id, 0, b"again"),
681            Err(UploadError::OffsetMismatch { expected: 6 })
682        );
683    }
684
685    #[test]
686    fn two_sessions_may_not_target_the_same_path() {
687        let (_dir, root, store) = store();
688        store
689            .create(&root, "out.bin".into(), 11, HELLO_DIGEST.into())
690            .expect("first");
691        assert_eq!(
692            store.create(&root, "out.bin".into(), 11, HELLO_DIGEST.into()),
693            Err(UploadError::Conflict)
694        );
695    }
696
697    #[test]
698    fn a_matching_checksum_completes() {
699        let (_dir, root, store) = store();
700        let id = store
701            .create(&root, "out.bin".into(), 11, HELLO_DIGEST.into())
702            .expect("create");
703        store.append(&id, 0, b"hello world").expect("append");
704
705        let finished = store.take_for_complete(&id).expect("complete");
706        assert_eq!(finished.bytes, 11);
707        assert_eq!(finished.digest, HELLO_DIGEST);
708        assert_eq!(finished.dest_rel, "out.bin");
709    }
710
711    #[test]
712    fn a_mismatched_checksum_is_refused() {
713        let (_dir, root, store) = store();
714        let wrong = "0".repeat(64);
715        let id = store
716            .create(&root, "out.bin".into(), 11, wrong.clone())
717            .expect("create");
718        store.append(&id, 0, b"hello world").expect("append");
719
720        match store.take_for_complete(&id) {
721            Err(UploadError::Checksum {
722                expected,
723                actual,
724                dest_rel,
725            }) => {
726                assert_eq!(expected, wrong);
727                assert_eq!(actual, HELLO_DIGEST);
728                assert_eq!(dest_rel, "out.bin");
729            }
730            other => panic!("expected a checksum refusal, got {other:?}"),
731        }
732        // The session is gone and the staging file with it.
733        assert_eq!(store.offset(&id), None);
734    }
735
736    #[test]
737    fn a_chunk_above_the_ceiling_is_refused() {
738        let (_dir, root, store) = store();
739        let id = store
740            .create(&root, "out.bin".into(), 11, HELLO_DIGEST.into())
741            .expect("create");
742        let oversized = vec![0_u8; DEFAULT_CHUNK_SIZE + 1];
743        assert_eq!(store.append(&id, 0, &oversized), Err(UploadError::TooLarge));
744    }
745
746    #[test]
747    fn a_chunk_that_would_exceed_the_declared_size_is_refused() {
748        let (_dir, root, store) = store();
749        // Declares 5 bytes; the digest is irrelevant here since the size
750        // check runs at `append` time, well before any checksum comparison.
751        let id = store
752            .create(&root, "out.bin".into(), 5, HELLO_DIGEST.into())
753            .expect("create");
754        assert_eq!(
755            store.append(&id, 0, b"hello world"),
756            Err(UploadError::SizeExceeded)
757        );
758        // Refused before anything was written: the offset must not have moved.
759        assert_eq!(store.offset(&id), Some(0));
760    }
761
762    #[test]
763    fn a_chunk_landing_exactly_on_the_declared_size_is_accepted() {
764        let (_dir, root, store) = store();
765        let id = store
766            .create(&root, "out.bin".into(), 11, HELLO_DIGEST.into())
767            .expect("create");
768        // Exactly 11 bytes against a declared size of 11 — the boundary
769        // `a_chunk_that_would_exceed_the_declared_size_is_refused` does not
770        // cover, and the one `>` (not `>=`) in the check depends on.
771        assert_eq!(store.append(&id, 0, b"hello world").expect("append"), 11);
772    }
773
774    #[test]
775    fn cancelling_removes_the_session_and_frees_the_destination() {
776        let (_dir, root, store) = store();
777        let id = store
778            .create(&root, "out.bin".into(), 11, HELLO_DIGEST.into())
779            .expect("create");
780        store.append(&id, 0, b"hello ").expect("append");
781
782        let (destination, bytes) = store.cancel(&id).expect("session existed");
783        assert_eq!(destination, "out.bin");
784        assert_eq!(bytes, 6);
785        assert_eq!(store.offset(&id), None);
786        // The destination is claimable again.
787        assert!(store
788            .create(&root, "out.bin".into(), 11, HELLO_DIGEST.into())
789            .is_ok());
790    }
791
792    #[test]
793    fn sweeping_drops_sessions_past_their_ttl() {
794        let (_dir, root, store) = store();
795        let id = store
796            .create(&root, "out.bin".into(), 11, HELLO_DIGEST.into())
797            .expect("create");
798
799        assert_eq!(store.sweep(Duration::ZERO).len(), 1);
800        assert_eq!(store.offset(&id), None);
801    }
802
803    /// `create` used to sweep opportunistically (with the real, fixed
804    /// `SESSION_TTL`) before doing anything else. That call is gone — moved
805    /// to the caller, which sweeps through the audit-aware
806    /// `sweep_expired_uploads` instead (`src/api/fs.rs`) — because
807    /// `UploadStore` has no `AuditSink` to record with, so a sweep run from
808    /// inside this method could never leave a trail. This is the regression
809    /// guard for that move: even a session that a zero-TTL sweep would call
810    /// stale must survive an unrelated `create` call untouched, proving
811    /// `create` itself no longer reclaims anything — only an explicit
812    /// `sweep`/`sweep_expired_uploads` call does.
813    #[test]
814    fn create_does_not_sweep_expired_sessions_itself() {
815        let (_dir, root, store) = store();
816        let id = store
817            .create(&root, "old.bin".into(), 11, HELLO_DIGEST.into())
818            .expect("create");
819
820        store
821            .create(&root, "new.bin".into(), 11, HELLO_DIGEST.into())
822            .expect("second create");
823
824        assert_eq!(
825            store.offset(&id),
826            Some(0),
827            "create must not silently reclaim a stale session; only an explicit sweep call may"
828        );
829    }
830
831    /// The strongest test in this module: `create` opens the staging file
832    /// with `create_new`, which must fail (`EEXIST`) rather than follow an
833    /// existing symlink at that exact name. Planted *before* any session
834    /// exists, exploiting that a fresh store's counter starts at 0 — so the
835    /// first session's id, and therefore its staging path, is predictable
836    /// (`up-0000000000000000.part`).
837    ///
838    /// Two assertions, not one: the create must fail, *and* the outside
839    /// target must be untouched. Checking only the error would still pass a
840    /// version that wrote through the link and then failed for an unrelated
841    /// reason afterward.
842    #[test]
843    fn a_pre_existing_symlink_at_the_predicted_staging_path_cannot_be_written_through() {
844        let outer = tempfile::tempdir().expect("outer tempdir");
845        let root_dir = outer.path().join("root");
846        std::fs::create_dir_all(&root_dir).expect("mkdir root");
847        let root = FsRoot::new(&root_dir).expect("root");
848        let store = UploadStore::new(DEFAULT_CHUNK_SIZE);
849
850        let secret = outer.path().join("secret.txt");
851        std::fs::write(&secret, b"outside-secret").expect("write secret");
852
853        let staging = UploadStore::staging_dir(&root);
854        std::fs::create_dir_all(&staging).expect("mkdir staging");
855        let predicted = staging.join("up-0000000000000000.part");
856
857        #[cfg(unix)]
858        let linked = std::os::unix::fs::symlink(&secret, &predicted).is_ok();
859        #[cfg(windows)]
860        let linked = std::os::windows::fs::symlink_file(&secret, &predicted).is_ok();
861        #[cfg(not(any(unix, windows)))]
862        let linked = false;
863        if !linked {
864            return; // symlink privilege unavailable on this runner; skip
865        }
866
867        let result = store.create(&root, "app-new.bin".into(), 11, HELLO_DIGEST.into());
868        assert!(
869            matches!(result, Err(UploadError::Io { .. })),
870            "create_new must refuse a pre-existing symlink at the staging path \
871             rather than follow it, got {result:?}"
872        );
873        assert_eq!(
874            std::fs::read(&secret).expect("read secret"),
875            b"outside-secret",
876            "the outside target must be untouched: the open must fail before \
877             any write reaches it"
878        );
879    }
880
881    #[test]
882    fn a_cap_limits_concurrent_sessions_and_releasing_one_frees_a_slot() {
883        let (_dir, root, store) = store();
884        let mut ids = Vec::with_capacity(MAX_CONCURRENT_UPLOADS);
885        for i in 0..MAX_CONCURRENT_UPLOADS {
886            let id = store
887                .create(&root, format!("f{i}.bin"), 1, HELLO_DIGEST.into())
888                .unwrap_or_else(|e| panic!("session {i} should fit under the cap: {e:?}"));
889            ids.push(id);
890        }
891
892        assert_eq!(
893            store.create(&root, "one-too-many.bin".into(), 1, HELLO_DIGEST.into()),
894            Err(UploadError::TooManySessions)
895        );
896
897        // Freeing one slot makes room for exactly one more.
898        assert!(store.cancel(&ids[0]).is_some());
899        assert!(store
900            .create(&root, "one-too-many.bin".into(), 1, HELLO_DIGEST.into())
901            .is_ok());
902    }
903
904    #[test]
905    fn completing_an_upload_keeps_the_destination_claimed_until_explicitly_released() {
906        let (_dir, root, store) = store();
907        let id = store
908            .create(&root, "out.bin".into(), 11, HELLO_DIGEST.into())
909            .expect("create");
910        store.append(&id, 0, b"hello world").expect("append");
911        let finished = store.take_for_complete(&id).expect("complete");
912
913        // The caller has not renamed the staging file into place yet (has not
914        // called `release_destination`), so the destination must still be
915        // refused to a second session — otherwise two sessions could both be
916        // mid-publication to the same path.
917        assert_eq!(
918            store.create(&root, "out.bin".into(), 11, HELLO_DIGEST.into()),
919            Err(UploadError::Conflict)
920        );
921
922        store.release_destination(&finished.dest_rel);
923
924        // Now that the caller is done with it, the destination is claimable again.
925        assert!(store
926            .create(&root, "out.bin".into(), 11, HELLO_DIGEST.into())
927            .is_ok());
928    }
929
930    #[test]
931    fn sweep_orphan_parts_removes_leftover_part_files_and_nothing_else() {
932        let dir = tempfile::tempdir().expect("tempdir");
933        let root = FsRoot::new(dir.path()).expect("root");
934        let staging = UploadStore::staging_dir(&root);
935        std::fs::create_dir_all(&staging).expect("mkdir staging");
936        std::fs::write(staging.join("up-0000000000000000.part"), b"leftover")
937            .expect("write orphan");
938        std::fs::write(staging.join("up-0000000000000001.part"), b"leftover2")
939            .expect("write second orphan");
940        // Not a `.part` file — proves the extension filter, not "delete
941        // everything in the directory".
942        std::fs::write(staging.join("keep.txt"), b"not a part file").expect("write keep");
943
944        let mut removed = sweep_orphan_parts(&root);
945        removed.sort();
946        assert_eq!(
947            removed,
948            vec![
949                ("up-0000000000000000".to_string(), 8),
950                ("up-0000000000000001".to_string(), 9),
951            ],
952            "each orphan must be reported by its id (the filename stem) and the bytes it held, so a caller can audit it"
953        );
954        assert!(!staging.join("up-0000000000000000.part").exists());
955        assert!(!staging.join("up-0000000000000001.part").exists());
956        assert!(
957            staging.join("keep.txt").exists(),
958            "only .part files are orphans; anything else in staging must survive"
959        );
960    }
961
962    #[test]
963    fn a_poisoned_sessions_lock_does_not_leak_the_claim_or_the_staging_file() {
964        let (_dir, root, store) = store();
965
966        // Poison `sessions` by panicking while holding its write guard.
967        // `catch_unwind` keeps the panic from taking the test process down;
968        // the guard's `Drop` still runs during the unwind and marks the
969        // lock poisoned regardless of the panic being caught afterward.
970        let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
971            let _guard = store.sessions.write().expect("lock not yet poisoned");
972            panic!("poison it");
973        }));
974        assert!(
975            poisoned.is_err(),
976            "the closure must have panicked while holding the write guard"
977        );
978
979        let outcome = store.create(&root, "out.bin".into(), 11, HELLO_DIGEST.into());
980        assert!(
981            matches!(outcome, Err(UploadError::Io { .. })),
982            "a poisoned sessions lock must surface as an Io error, got {outcome:?}"
983        );
984
985        // Recover the lock — a real caller cannot do this, but the test does,
986        // purely to inspect whether the failed attempt above left anything
987        // behind. If it did, this second `create` for the same destination
988        // would come back `Err(Conflict)` instead of succeeding.
989        store.sessions.clear_poison();
990        assert!(
991            store
992                .create(&root, "out.bin".into(), 11, HELLO_DIGEST.into())
993                .is_ok(),
994            "the destination must not still be claimed by the failed attempt"
995        );
996
997        let staging = UploadStore::staging_dir(&root);
998        let leftover_parts = std::fs::read_dir(&staging)
999            .expect("staging dir")
1000            .flatten()
1001            .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("part"))
1002            .count();
1003        assert_eq!(
1004            leftover_parts, 1,
1005            "only the second, successful session's staging file should remain"
1006        );
1007    }
1008}