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;
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/// Chunk size advertised instead of [`DEFAULT_CHUNK_SIZE`] when this device
29/// reached its operator through a relay.
30///
31/// **`DEFAULT_CHUNK_SIZE` was chosen against the wrong constraint.** Its
32/// reasoning (above) weighs the relay's *size* ceiling, `relay::MAX_BODY`, and
33/// mentions the 120s request deadline only as "tight for 8 MiB". But the relay
34/// buffers a whole request body and forwards it as one WebSocket frame
35/// (`relay::forward`), while `relay::proxy::REQUEST_TIMEOUT` is a **fixed
36/// total** — so the time a chunk needs scales with its size and the budget it
37/// must fit into does not. That makes the deadline, not the size ceiling, the
38/// binding constraint, and 4 MiB silently requires the relay→device leg to
39/// sustain roughly 35 KB/s. Below that the transfer does not merely run slow:
40/// it fails at zero bytes, every time, with no partial progress — a `504` the
41/// caller cannot distinguish from a broken link. Live report: 4 MiB failed at
42/// 0 bytes on eight consecutive attempts across two unrelated links, while
43/// smaller chunks over the same links progressed.
44///
45/// So this value is derived from the deadline rather than picked from a
46/// measurement:
47///
48/// * `RELAY_FLOOR_THROUGHPUT` — the slowest relay→device leg a relayed upload
49///   is expected to work over at all.
50/// * `REQUEST_TIMEOUT` (120s) — the whole budget one chunk's round trip has.
51/// * `RELAY_DEADLINE_FRACTION` — how much of that budget one chunk may spend.
52///   Not 1: the budget also covers the response leg and the device's own write
53///   and hash, and observed transfer times vary by more than 3x on a poor
54///   link, so a chunk sized to *just* fit the deadline fails intermittently.
55///
56/// The result is rounded down to a power of two. At 256 KiB a chunk fits the
57/// deadline on any link sustaining ~2.2 KB/s, which is far below the slowest
58/// link anyone has reported this failing on.
59///
60/// Deliberately *not* applied per request. The relay injects no marker header
61/// and replays the caller's own headers verbatim, so a handler cannot tell a
62/// relayed request from a direct one; what the device does know is whether it
63/// joined a relay at all. A directly-reached caller on a relay-joined device
64/// is therefore told a smaller size than it strictly needs — an extra round
65/// trip, in the fail-safe direction — which is the honest trade for not
66/// inventing a signal that does not exist.
67pub const RELAY_CHUNK_SIZE: usize = {
68    let ceiling = RELAY_FLOOR_THROUGHPUT * REQUEST_TIMEOUT_SECS / RELAY_DEADLINE_FRACTION;
69    // Round down to a power of two, so the advertised number is one a client
70    // can align buffers to. `ceiling` is 480 KiB today, which rounds to 256.
71    let mut size = 1;
72    while size * 2 <= ceiling {
73        size *= 2;
74    }
75    size
76};
77
78/// Slowest relay→device leg a relayed upload is expected to work over.
79const RELAY_FLOOR_THROUGHPUT: usize = 16 * 1024;
80
81/// The relay's own request deadline, read rather than copied.
82///
83/// This module reaching into `relay` for one constant is a dependency worth
84/// paying: the number it needs *is* the relay's deadline, and the alternative
85/// — a hand-copied `120` guarded by a test asserting the two agree — keeps a
86/// second source of truth alive for no gain. (That copy was written here
87/// first, on the belief that `Duration::as_secs` was not callable in a
88/// `const`. It is, and has been since well before this crate's MSRV; the
89/// belief was never checked before it was acted on.)
90const REQUEST_TIMEOUT_SECS: usize = crate::relay::proxy::REQUEST_TIMEOUT.as_secs() as usize;
91
92/// Share of the relay's request deadline one chunk may consume.
93const RELAY_DEADLINE_FRACTION: usize = 4;
94
95/// The three properties [`RELAY_CHUNK_SIZE`] exists to hold, asserted at
96/// compile time rather than in a test.
97///
98/// Every input is a `const`, so a test asserting these could only ever fail
99/// on a build that already compiled — which is the wrong moment. As
100/// `const _`, an edit that breaks one of them fails the build at the line
101/// that broke it. (Clippy points this out as `assertions_on_constants`; it is
102/// right, and the stronger form is also the clearer one.)
103///
104/// The relay's deadline needs no assertion of its own: it is read from
105/// `relay::proxy::REQUEST_TIMEOUT` directly, so there is no copy to drift.
106const _: () = {
107    // 1. A chunk clears the deadline on the slowest link claimed to be
108    //    supported, within the share of the budget a chunk may spend.
109    assert!(
110        RELAY_CHUNK_SIZE / RELAY_FLOOR_THROUGHPUT <= REQUEST_TIMEOUT_SECS / RELAY_DEADLINE_FRACTION,
111        "a relay-path chunk must fit its share of the relay's deadline at the floor throughput"
112    );
113    // 2. A size a client can align buffers to.
114    assert!(RELAY_CHUNK_SIZE.is_power_of_two());
115    // 3. The defect, stated as an invariant: the direct default does *not*
116    //    fit that deadline at the floor throughput, which is the entire
117    //    reason a separate relay-path value exists. If a future edit ever
118    //    makes it fit, delete this constant rather than keeping two sizes
119    //    that mean the same thing.
120    assert!(
121        DEFAULT_CHUNK_SIZE / RELAY_FLOOR_THROUGHPUT > REQUEST_TIMEOUT_SECS,
122        "the direct default now fits the relay deadline — RELAY_CHUNK_SIZE is obsolete"
123    );
124};
125
126/// How long a session may sit idle before it is swept.
127pub const SESSION_TTL: Duration = Duration::from_secs(3600);
128
129/// Largest number of upload sessions this process holds open at once.
130///
131/// A session holds an open file handle for up to `SESSION_TTL` (an hour). The
132/// only credential `POST /uploads` requires is `fs.write` — so without a cap,
133/// a token scoped to nothing but `fs.write` could open enough sessions to
134/// exhaust the process's file descriptors, and fd exhaustion is process-wide:
135/// it would degrade `exec` and `session` routes too, which that token has no
136/// capability over at all. That makes this a capability-boundary issue, not
137/// merely a disk-quota one, so it belongs in this endpoint rather than being
138/// left to an operator-configured limit nobody has asked for yet.
139///
140/// 128 is a fixed constant rather than a CLI knob: generous enough that no
141/// legitimate concurrent-upload workload should hit it, small enough that the
142/// worst case (128 open file handles) is nowhere near typical per-process fd
143/// limits (1024+ on Linux, comparable on Windows). Not configurable — YAGNI
144/// until an operator actually needs a different number.
145const MAX_CONCURRENT_UPLOADS: usize = 128;
146
147/// Why an upload operation was refused.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum UploadError {
150    /// No such session (unknown id, or already completed/cancelled/expired).
151    NotFound,
152    /// The chunk did not start where the session expects.
153    OffsetMismatch { expected: u64 },
154    /// Another live session already targets this destination.
155    ///
156    /// Carries the id of the session holding the claim. Without it a caller
157    /// that lost a transfer to a timeout is told the destination is busy and
158    /// given no way to reach the session that is busy with it — it can
159    /// neither resume that session nor cancel it, because every route to a
160    /// session is keyed by an id this refusal declined to name. A live report
161    /// had a consumer enumerating `up-%016d` from zero to find it, which is
162    /// both not a contract and the wrong number base (ids are hex).
163    Conflict { upload_id: String },
164    /// The chunk exceeds the advertised chunk size.
165    TooLarge,
166    /// This chunk would push the session past the size declared at creation.
167    SizeExceeded,
168    /// Too many sessions are already open (see `MAX_CONCURRENT_UPLOADS`).
169    TooManySessions,
170    /// The assembled bytes do not hash to what was declared.
171    Checksum {
172        expected: String,
173        actual: String,
174        /// The destination the rejected session was headed for. Widened for
175        /// this field for the same reason `UploadStore::cancel`'s return
176        /// type was: an audit event for a terminal upload outcome should be
177        /// able to name its subject. `dest_rel` is not consumed before this
178        /// variant is built — `take_for_complete` only borrows it (for
179        /// `self.release(&dest_rel)`) beforehand — so there was never a
180        /// reason it could not be carried here; the field was simply never
181        /// added.
182        dest_rel: String,
183    },
184    /// The filesystem refused.
185    Io {
186        /// Already-rendered detail (`ToString` of the underlying
187        /// `io::Error`). A `String`, not the `io::Error` itself: `UploadError`
188        /// derives `Clone`/`PartialEq`/`Eq`, neither of which `io::Error`
189        /// implements.
190        detail: String,
191        /// The underlying `io::Error`'s `raw_os_error()`, carried alongside
192        /// `detail` so a caller can tell ENOSPC apart from an unrelated
193        /// failure without a locale-dependent match on the rendered message
194        /// — see `platform::is_out_of_space`, which `upload_error_response`
195        /// (`src/api/fs.rs`) uses this to answer.
196        raw_os_error: Option<i32>,
197    },
198}
199
200impl From<std::io::Error> for UploadError {
201    fn from(e: std::io::Error) -> Self {
202        UploadError::Io {
203            raw_os_error: e.raw_os_error(),
204            detail: e.to_string(),
205        }
206    }
207}
208
209/// A session whose bytes are all in and whose digest has been computed.
210#[derive(Debug, Clone)]
211pub struct FinishedUpload {
212    pub dest_rel: String,
213    pub part_path: PathBuf,
214    pub bytes: u64,
215    pub digest: String,
216    pub expected: String,
217}
218
219/// One in-flight upload.
220struct Session {
221    dest_rel: String,
222    /// Absolute canonicalized path to the staging file. Always built from a
223    /// canonical prefix (the staging directory derived from an absolute
224    /// destination). `has_live_part_under` compares this against caller-supplied
225    /// input using `starts_with`, which requires both paths to be canonical.
226    /// Building `part_path` from a non-canonicalized destination path would
227    /// break that comparison silently, causing the query to return `false` even
228    /// when a session is actually under the queried directory.
229    part_path: PathBuf,
230    declared_size: u64,
231    declared_sha256: String,
232    offset: u64,
233    hasher: Hasher,
234    file: std::fs::File,
235    touched: Instant,
236}
237
238/// All in-flight uploads for this process.
239///
240/// State lives in memory only. A restart loses sessions and the client starts
241/// over; persisting them would mean reconstructing a partial hash across
242/// processes, which is a durability feature nobody has asked for yet. The
243/// staging files a restart leaves behind are swept on startup
244/// (`sweep_orphan_parts`).
245///
246/// Invariant: no method ever holds the `sessions` lock and the `claimed`
247/// lock at the same time. `create` takes `claimed` (in its own block, which
248/// closes before anything else runs) and only later, separately, takes
249/// `sessions`; `cancel`, `sweep`, and `take_for_complete` take `sessions`
250/// first and always drop that guard — explicitly, where it is not the last
251/// use in the enclosing statement — before reaching `claimed` through
252/// `release`/`release_destination`. That the two methods' orderings are
253/// opposite (`claimed` before `sessions` in one, `sessions` before `claimed`
254/// in the other) would be a textbook two-lock deadlock *if* either ever held
255/// both at once; because neither does, the orderings never actually nest and
256/// there is nothing to cycle on. Preserving this is what makes `sessions`
257/// vs. `claimed` safe to reason about independently of `append`'s
258/// documented (non-deadlocking) contention with `sweep` — see `append`'s
259/// doc comment for that argument. Breaking this invariant — folding a
260/// `claimed` access inside a still-held `sessions` guard, or vice versa —
261/// would reintroduce a real deadlock that no existing test would catch.
262pub struct UploadStore {
263    sessions: RwLock<HashMap<String, Mutex<Session>>>,
264    /// Destinations currently claimed, so two sessions cannot race to one path,
265    /// each mapped to the staging directory it stages through and the session
266    /// that holds it.
267    ///
268    /// A map, not a set. It was a set for as long as nothing read a value out
269    /// of it (an earlier version stored the claiming session's id, which
270    /// nothing looked up — `sessions` is the source of truth for which id owns
271    /// which destination). `release` then read one: it reclaims an empty
272    /// staging directory once no remaining claim stages through it, and "which
273    /// directory, and is anyone else using it" is a question only this map can
274    /// answer under a single lock. Machine-wide, staging follows each
275    /// destination, so two claims routinely name two different directories and
276    /// a set could not tell them apart.
277    ///
278    /// The owning id is back, and this time something *does* look it up:
279    /// `create` reports it in `UploadError::Conflict` so a refused caller can
280    /// reach the session that refused it. `sessions` remains the source of
281    /// truth for what a session *is* — this is a back-reference, and the two
282    /// cannot be consulted together without breaking the lock invariant
283    /// below, which is exactly why the id has to live here rather than be
284    /// looked up from `sessions` at refusal time.
285    ///
286    /// Its length still doubles as the live-session count for
287    /// `MAX_CONCURRENT_UPLOADS`: every live session claims exactly one
288    /// destination and every destination is claimed by at most one session, so
289    /// checking `claimed.len()` under `claimed`'s own lock is an atomic
290    /// admission check — two concurrent callers cannot both read a count under
291    /// the cap and then both insert, because the check and the insert share
292    /// one critical section.
293    claimed: Mutex<HashMap<String, Claim>>,
294    chunk_size: usize,
295    counter: std::sync::atomic::AtomicU64,
296}
297
298/// What a claimed destination records: where it stages, and who holds it.
299#[derive(Debug, Clone)]
300struct Claim {
301    staging: PathBuf,
302    upload_id: String,
303}
304
305impl UploadStore {
306    pub fn new(chunk_size: usize) -> Self {
307        Self {
308            sessions: RwLock::new(HashMap::new()),
309            claimed: Mutex::new(HashMap::new()),
310            // Upper bound one *less* than `MAX_CHUNK_SIZE`, matching what
311            // `--fs-chunk-size`'s own startup check enforces (`main.rs`
312            // exits for `size >= MAX_CHUNK_SIZE`). Clamping to
313            // `MAX_CHUNK_SIZE` itself (an earlier version did) is not
314            // reachable through the CLI today, but it is worse than
315            // unreachable: it would silently *accept* exactly the value the
316            // CLI's own check exists to refuse, for any future caller that
317            // constructs a store directly rather than through the CLI.
318            chunk_size: chunk_size.clamp(1, MAX_CHUNK_SIZE - 1),
319            counter: std::sync::atomic::AtomicU64::new(0),
320        }
321    }
322
323    /// The chunk size clients are told to use.
324    pub fn chunk_size(&self) -> usize {
325        self.chunk_size
326    }
327
328    /// Where staging files live for an upload landing at `dest_abs`.
329    ///
330    /// Inside a jail that is one directory at the root, as it has always been.
331    /// Machine-wide there is no single place it could be: `complete` publishes
332    /// by `rename`, which is only atomic within a filesystem, so staging has to
333    /// sit on the same one as the destination. Windows makes this unavoidable
334    /// rather than merely preferable — a staging directory on `C:` cannot be
335    /// renamed onto `D:` at all.
336    ///
337    /// Taking the destination's own parent, rather than the volume root, keeps
338    /// that guarantee on Unix too, where a mount point below `/` is a different
339    /// filesystem and `/` is usually not writable by the account running this.
340    ///
341    /// The cost is that machine-wide staging is no longer one enumerable
342    /// directory, which is what `sweep_orphan_parts` needs — see its doc.
343    pub fn staging_dir(root: &crate::fs::FsRoot, dest_abs: &Path) -> PathBuf {
344        match root.jail_path() {
345            Some(jail) => jail.join(UPLOAD_DIR),
346            None => match dest_abs.parent() {
347                Some(parent) => parent.join(UPLOAD_DIR),
348                // A destination with no parent is a filesystem anchor, which
349                // `resolve_for_create` already refuses as a create target.
350                None => PathBuf::from(UPLOAD_DIR),
351            },
352        }
353    }
354
355    /// 살아있는 세션 중 스테이징 파일이 `dir` 아래에 있는 것이 하나라도 있는가.
356    ///
357    /// 트리 삭제가 진행 중인 업로드를 지우지 않기 위한 조회다. 근거는 **세션
358    /// 목록이지 디스크가 아니다**: 이전 실행이 남긴 고아 `.part`는 아무도
359    /// 소유하지 않으므로 "살아있음"이 아니고, 그것까지 살아있다고 답하면
360    /// 스윕이 아직 닿지 않은 트리가 무기한 삭제 불가가 된다.
361    ///
362    /// `sessions` 락만 잡는다. `claimed`을 함께 잡으면 이 타입의 락 불변식이
363    /// 깨진다 — 그 이유는 `UploadStore`의 doc comment에 있다.
364    pub fn has_live_part_under(&self, dir: &Path) -> bool {
365        let Ok(sessions) = self.sessions.read() else {
366            // 락이 오염됐다면 "없다"고 답할 근거가 없다. 삭제를 막는 쪽이
367            // 안전하다 — 이 조회의 유일한 소비자가 그렇게 쓴다.
368            return true;
369        };
370        // 캐노니칼화 실패(경로가 존재하지 않음 등)는 안전하게 "있다"고 답한다.
371        // `part_path`는 항상 정규화된 절대경로이고, 정규화되지 않은 경로와는
372        // 비교할 수 없다. 확인할 수 없는 경우 삭제를 막는 쪽이 안전하다
373        // — 이것은 위의 락 오염 처리와 동일한 입장이다.
374        let Ok(canonical_dir) = std::fs::canonicalize(dir) else {
375            return true;
376        };
377        sessions.values().any(|session| {
378            session
379                .lock()
380                .map(|s| s.part_path.starts_with(&canonical_dir))
381                .unwrap_or(true)
382        })
383    }
384
385    /// Open a session for `dest_rel`, which need not exist yet.
386    ///
387    /// Does *not* sweep expired sessions itself, even opportunistically — an
388    /// earlier version did, right here, before anything else ran. That
389    /// silently discarded whatever session the sweep reclaimed: `UploadStore`
390    /// has no `AuditSink` to record with, so a sweep run from inside this
391    /// method structurally cannot leave a trail. The caller
392    /// (`create_upload_blocking`, `src/api/fs.rs`) now sweeps via the
393    /// audit-aware `sweep_expired_uploads` immediately before calling this,
394    /// preserving the ordering the old internal call existed for: reclaim
395    /// stale capacity before the cap check below runs, so a session old
396    /// enough to matter is freed the moment somebody next asks for a new one
397    /// — the same guarantee, just recorded now instead of silent.
398    pub fn create(
399        &self,
400        root: &crate::fs::FsRoot,
401        dest_abs: &Path,
402        dest_rel: String,
403        size: u64,
404        sha256: String,
405    ) -> Result<String, UploadError> {
406        // Computed before the claim rather than after it, and recorded *with*
407        // the claim: `release` reclaims this directory once it is empty and no
408        // remaining claim stages through it, and it decides that under the
409        // same lock this insert takes. Claiming first is therefore what makes
410        // the directory safe to create afterwards — a concurrent `release`
411        // cannot be between "no other claim" and `remove_dir` while this claim
412        // is already in the map, so it can never delete the directory out from
413        // under the `create_dir_all` and `create_new` below.
414        let staging = Self::staging_dir(root, dest_abs);
415
416        // Minted before the claim rather than after it, because the claim now
417        // records who holds it and a claim cannot name an id that does not
418        // exist yet. The cost is that a refused `create` still consumes a
419        // serial, so ids are not contiguous — which was never a property
420        // anything could rely on (`sweep_orphan_parts` parses a stem, it does
421        // not enumerate), and the one caller who *did* try to enumerate them
422        // is the reason `Conflict` carries an id at all.
423        let serial = self
424            .counter
425            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
426        // `{serial:016x}` is fixed-width lowercase hex of a server-generated
427        // counter — never caller input — so `staging.join` below only ever
428        // appends exactly one ordinary, `..`-free, drive-prefix-free
429        // component. The postcondition `delete_file_blocking` needs for a
430        // caller-supplied name (`src/api/fs.rs`) has nothing to check here.
431        let id = format!("up-{serial:016x}");
432
433        // Claim the destination first: a second session for the same path is a
434        // silent-overwrite race, and last-writer-wins loses data quietly.
435        {
436            let mut claimed = self.claimed.lock().map_err(|_| poisoned())?;
437            if let Some(holder) = claimed.get(&dest_rel) {
438                return Err(UploadError::Conflict {
439                    upload_id: holder.upload_id.clone(),
440                });
441            }
442            if claimed.len() >= MAX_CONCURRENT_UPLOADS {
443                return Err(UploadError::TooManySessions);
444            }
445            claimed.insert(
446                dest_rel.clone(),
447                Claim {
448                    staging: staging.clone(),
449                    upload_id: id.clone(),
450                },
451            );
452        }
453
454        if let Err(e) = std::fs::create_dir_all(&staging) {
455            self.release(&dest_rel);
456            return Err(e.into());
457        }
458
459        let part_path = staging.join(format!("{id}.part"));
460
461        // `create_new` (`O_EXCL` on Unix, `CREATE_NEW` on Windows), not
462        // `File::create` (`O_CREAT|O_TRUNC`, no `O_EXCL`). Containment is a
463        // property of the moment of resolution, and a session here can live
464        // for up to `SESSION_TTL` — long enough for `part_path`'s final
465        // component to become a symlink pointing outside the root before this
466        // call runs. `File::create` would follow it and write outside the
467        // jail; `create_new` fails `EEXIST` on an existing name instead of
468        // following it, symlink or not. `part_path` above is safe by
469        // construction (server-generated id), so nothing should exist at this
470        // exact name yet — `create_new` is what makes "should" load-bearing
471        // instead of assumed.
472        let file = match std::fs::OpenOptions::new()
473            .write(true)
474            .create_new(true)
475            .open(&part_path)
476        {
477            Ok(file) => file,
478            Err(e) => {
479                self.release(&dest_rel);
480                return Err(e.into());
481            }
482        };
483
484        let session = Session {
485            dest_rel: dest_rel.clone(),
486            part_path,
487            declared_size: size,
488            declared_sha256: sha256,
489            offset: 0,
490            hasher: Hasher::new(),
491            file,
492            touched: Instant::now(),
493        };
494
495        // Matched rather than `?`-chained (an earlier version chained
496        // `.write().map_err(...)?.insert(...)`): a poisoned `sessions` lock
497        // must not leak the claim or the staging file already created above
498        // — `session` is not moved into the map on this path, so its
499        // `part_path` is still reachable to clean up. `sweep`/`cancel` only
500        // ever reclaim a claim through a *live* entry in `sessions`; if this
501        // session never made it into that map, nothing else will ever
502        // release it.
503        let mut sessions = match self.sessions.write() {
504            Ok(sessions) => sessions,
505            Err(_) => {
506                std::fs::remove_file(&session.part_path).ok();
507                self.release(&dest_rel);
508                return Err(poisoned());
509            }
510        };
511        sessions.insert(id.clone(), Mutex::new(session));
512        drop(sessions);
513
514        // The claim was inserted at the top of this function and nothing on
515        // the path to here removes it — every `release` above is followed by
516        // `return Err`. A re-insert stood here and was a no-op for a set; for
517        // a map it would be worse than redundant, since it could resurrect a
518        // claim that a `sweep` running between the `sessions.insert` above and
519        // this line had just released, leaving a destination claimed by a
520        // session that no longer exists.
521        Ok(id)
522    }
523
524    /// How many bytes the session has accepted so far.
525    pub fn offset(&self, id: &str) -> Option<u64> {
526        let sessions = self.sessions.read().ok()?;
527        let session = sessions.get(id)?.lock().ok()?;
528        Some(session.offset)
529    }
530
531    /// Append one chunk, returning the offset to send next.
532    ///
533    /// The offset is checked rather than trusted: a retried request that
534    /// already landed would otherwise be written twice and corrupt the hash.
535    ///
536    /// This holds the session's own `Mutex` (and the store-wide `sessions`
537    /// `RwLock` in its shared read mode) for the full duration of the
538    /// `seek`+`write_all` below, which is blocking disk I/O — not merely an
539    /// in-memory update. That means a concurrent `sweep` (which needs
540    /// `sessions`' *write* lock) blocks until this call finishes, and so does
541    /// any other caller of `append`/`take_for_complete`/`cancel` for this same
542    /// session id (nothing else can, since only one request should ever be
543    /// live per session anyway). Concurrent `append` calls for *different*
544    /// sessions are unaffected — `RwLock` read access is shared. Accepted:
545    /// bounded by one write's duration. This is not the only possible
546    /// shape, though: `HashMap<String, Arc<Mutex<Session>>>` would let this
547    /// function clone the `Arc`, drop the `sessions` read guard immediately,
548    /// and hold only the session's own `Mutex` across the write — removing
549    /// the contention with `sweep`/`create` entirely while keeping the same
550    /// per-session serialization (two chunks for the *same* session still
551    /// cannot interleave, since the session `Mutex` alone already prevents
552    /// that). That is a real improvement, tracked separately rather than
553    /// made here: it restructures the state machine's storage at the tail
554    /// of an already-large task, and the contention it would remove is
555    /// bounded and documented, not a correctness gap.
556    ///
557    /// One failure mode this contention analysis does not cover: if a writer
558    /// ever panicked while holding `sessions`' *write* guard (`create`'s
559    /// insert, `take_for_complete`'s or `sweep`'s remove), `std::sync::RwLock`
560    /// poisons permanently — every later `.read()` and `.write()` on it,
561    /// including this method's and `sweep`'s own, then fails identically and
562    /// forever, not "eventually swept once the panic clears." Unreachable
563    /// today, specifically because every write-lock section in this file is a
564    /// plain `HashMap` insert or remove with no disk I/O and nothing else
565    /// that can panic — not because poisoning itself is impossible. That is
566    /// the condition this note depends on, not a permanent property of the
567    /// type: if a future change adds a fallible operation (a write, a
568    /// panicking conversion, anything that can unwind) inside one of those
569    /// three write-lock sections, this analysis no longer holds and the
570    /// unreachability claim needs re-checking against whatever was added.
571    ///
572    /// This says nothing about the per-session `Mutex` acquired below, which
573    /// *is* held across the `seek`+`write_all` disk I/O this doc comment
574    /// itself describes. It is unreachable for the same underlying reason,
575    /// not the same argument: `seek` and `write_all` report failure through
576    /// `Result`, propagated with `?` rather than unwound, so nothing in that
577    /// critical section can panic either.
578    pub fn append(&self, id: &str, offset: u64, bytes: &[u8]) -> Result<u64, UploadError> {
579        if bytes.len() > self.chunk_size {
580            return Err(UploadError::TooLarge);
581        }
582
583        let sessions = self.sessions.read().map_err(|_| poisoned())?;
584        let cell = sessions.get(id).ok_or(UploadError::NotFound)?;
585        let mut session = cell.lock().map_err(|_| poisoned())?;
586
587        if offset != session.offset {
588            return Err(UploadError::OffsetMismatch {
589                expected: session.offset,
590            });
591        }
592
593        // Refused before a single byte is written: without this, a session
594        // can stream arbitrarily far past what it declared, and the mismatch
595        // is only ever caught at `complete` — after every byte has already
596        // hit disk. `checked_add` rather than a plain `+`: `offset` is
597        // caller-supplied (via `Content-Range`) and could in principle be
598        // adversarially close to `u64::MAX`; overflow is treated the same as
599        // exceeding the declared size, not as a wrapped-around pass.
600        let next_offset = offset.checked_add(bytes.len() as u64);
601        if next_offset.map_or(true, |next| next > session.declared_size) {
602            return Err(UploadError::SizeExceeded);
603        }
604
605        session
606            .file
607            .seek(SeekFrom::Start(offset))
608            .map_err(UploadError::from)?;
609        session.file.write_all(bytes).map_err(UploadError::from)?;
610
611        session.hasher.update(bytes);
612        session.offset += bytes.len() as u64;
613        session.touched = Instant::now();
614        Ok(session.offset)
615    }
616
617    /// Finish a session: verify the digest and hand back the staging file.
618    ///
619    /// The session is always removed from `sessions`. The destination's
620    /// *claim*, however, survives a successful call — see
621    /// `release_destination`'s doc comment for why. A failed checksum is
622    /// different: it is terminal (the bytes on disk are known-wrong, and
623    /// leaving them resumable would invite a client to retry into the same
624    /// wrong result), and terminal means no rename will ever follow, so the
625    /// claim is released immediately in that case — there is nothing left for
626    /// a caller to finish acting on.
627    pub fn take_for_complete(&self, id: &str) -> Result<FinishedUpload, UploadError> {
628        let cell = self
629            .sessions
630            .write()
631            .map_err(|_| poisoned())?
632            .remove(id)
633            .ok_or(UploadError::NotFound)?;
634        let session = cell.into_inner().map_err(|_| poisoned())?;
635
636        let Session {
637            dest_rel,
638            part_path,
639            declared_size,
640            declared_sha256,
641            offset,
642            hasher,
643            file,
644            ..
645        } = session;
646        drop(file);
647
648        let digest = hasher.finish();
649        if declared_size != offset || digest != declared_sha256 {
650            std::fs::remove_file(&part_path).ok();
651            self.release(&dest_rel);
652            return Err(UploadError::Checksum {
653                expected: declared_sha256,
654                actual: digest,
655                dest_rel,
656            });
657        }
658
659        // Deliberately not released here — see `release_destination`.
660        Ok(FinishedUpload {
661            dest_rel,
662            part_path,
663            bytes: offset,
664            digest: digest.clone(),
665            expected: declared_sha256,
666        })
667    }
668
669    /// Release a destination's claim once the caller has finished acting on
670    /// the `FinishedUpload` a prior `take_for_complete` handed back — after
671    /// the rename lands, or after giving up on it (whichever the caller's
672    /// last step was).
673    ///
674    /// Not folded into `take_for_complete` itself: an earlier version of this
675    /// function released the claim there, immediately on success — which
676    /// opened a window between "session removed, claim released" and
677    /// "staging file renamed into place" where a second `create` for the same
678    /// `dest_rel` could succeed and start its own rename racing the first's,
679    /// defeating the reason `claimed` exists at all. Keeping the claim alive
680    /// until the caller explicitly releases it closes that window; the caller
681    /// (`complete_upload` in `src/api/fs.rs`) calls this on every exit path
682    /// after `take_for_complete` succeeds, success or failure of the rename
683    /// alike, so the claim is always released exactly once.
684    pub fn release_destination(&self, dest_rel: &str) {
685        self.release(dest_rel);
686    }
687
688    /// Discard a session and its staging file.
689    ///
690    /// Returns the destination and bytes received so far when a session
691    /// existed to cancel — `None` means no such session (unknown, already
692    /// completed, already cancelled, or already expired). Widened from a
693    /// plain `bool` for the same reason `sweep` returns
694    /// `(id, destination, bytes_received)` instead of just dropping what it
695    /// finds: the caller (`cancel_upload` in `src/api/fs.rs`) records a
696    /// terminal audit event, and an event that cannot name which file was
697    /// cancelled answers only "a session ended", not "what happened to this
698    /// file" — the question an audit trail exists to answer.
699    pub fn cancel(&self, id: &str) -> Option<(String, u64)> {
700        let Ok(mut sessions) = self.sessions.write() else {
701            return None;
702        };
703        let cell = sessions.remove(id)?;
704        // Load-bearing, not tidiness: `release` below takes the `claimed`
705        // lock, and `UploadStore`'s struct-level invariant is that `sessions`
706        // and `claimed` are never held at once. Removing this `drop` would
707        // still compile — `sessions` is unused after this point — but would
708        // hold the `sessions` write guard across the `claimed` acquisition,
709        // breaking that invariant silently.
710        drop(sessions);
711        let Ok(session) = cell.into_inner() else {
712            return None;
713        };
714        // Staging file first, claim second. `release` reclaims the staging
715        // directory when the last claim through it goes, and `remove_dir`
716        // refuses a directory that still holds this session's `.part` — so
717        // releasing first leaves exactly the empty directory this is meant to
718        // clear. The reverse order is safe for the claim too: nothing else can
719        // take this destination while the claim is still held.
720        std::fs::remove_file(&session.part_path).ok();
721        self.release(&session.dest_rel);
722        Some((session.dest_rel, session.offset))
723    }
724
725    /// Drop sessions idle for longer than `ttl`.
726    ///
727    /// Returns `(id, destination, bytes_received)` for each, so the caller can
728    /// record a terminal audit event. A session that begins and never ends
729    /// leaves a trail showing only a beginning, which is not a trail.
730    pub fn sweep(&self, ttl: Duration) -> Vec<(String, String, u64)> {
731        let mut expired = Vec::new();
732        let Ok(sessions) = self.sessions.read() else {
733            return expired;
734        };
735        let stale: Vec<String> = sessions
736            .iter()
737            .filter(|(_, cell)| {
738                cell.lock()
739                    .map(|s| s.touched.elapsed() >= ttl)
740                    .unwrap_or(false)
741            })
742            .map(|(id, _)| id.clone())
743            .collect();
744        // Load-bearing: `std::sync::RwLock` has no upgrade from a read guard
745        // to a write guard, so holding this one into the loop below (which
746        // needs `sessions.write()`) would deadlock this thread against
747        // itself — a different hazard from the `drop` inside the loop below.
748        drop(sessions);
749
750        for id in stale {
751            let Ok(mut sessions) = self.sessions.write() else {
752                break;
753            };
754            let Some(cell) = sessions.remove(&id) else {
755                continue;
756            };
757            // Load-bearing, not tidiness — same reason as `cancel`'s:
758            // `release` below takes `claimed`, and `UploadStore`'s
759            // struct-level invariant is that `sessions` and `claimed` are
760            // never held at once.
761            drop(sessions);
762            if let Ok(session) = cell.into_inner() {
763                // Staging file before claim, for the reason `cancel` states.
764                std::fs::remove_file(&session.part_path).ok();
765                self.release(&session.dest_rel);
766                expired.push((id, session.dest_rel, session.offset));
767            }
768        }
769        expired
770    }
771
772    /// Drop a destination's claim, and reclaim its staging directory if that
773    /// was the last claim staging through it.
774    ///
775    /// The directory is this API's own artifact, and until now nothing removed
776    /// it: `.part` files were swept but the `.shell-tunnel-uploads` directory
777    /// holding them stayed forever, invisible to `list` and refused by `stat`
778    /// and `delete` alike — an artifact the file API created and the file API
779    /// could not remove. Machine-wide that is one per directory anyone has
780    /// ever uploaded to. Whoever made it cleans it up, which is also why this
781    /// does not instead relax the reservation guard on the delete route.
782    ///
783    /// **The `remove_dir` runs while the lock is held, deliberately.** Deciding
784    /// "no other claim stages here" and then releasing the lock before the
785    /// syscall reopens exactly the window this ordering closes: a `create` can
786    /// insert its claim and run `create_dir_all` in that gap, and the removal
787    /// would then delete the directory that create is about to place a `.part`
788    /// into. The cost is that `create`'s admission check can wait on one
789    /// `remove_dir`; shortening this critical section is not the optimisation
790    /// it looks like.
791    ///
792    /// `remove_dir`, never `remove_dir_all`: a directory holding another
793    /// session's staging file, or anything else, fails the call and is left
794    /// alone. The error is discarded because every reason it can fail — not
795    /// empty, already gone, no permission — is a reason to do nothing.
796    ///
797    /// Not extended to the orphan sweeps. `sweep_orphan_parts_in` is called
798    /// *from* `create`, one line before the directory is needed, and
799    /// `sweep_orphan_parts` runs on an interval without holding this lock;
800    /// removing a directory from either would race the very creation this
801    /// method's ordering protects. A directory left empty by a previous run's
802    /// crash is instead reclaimed the next time an upload through it finishes.
803    fn release(&self, dest_rel: &str) {
804        if let Ok(mut claimed) = self.claimed.lock() {
805            let Some(Claim { staging, .. }) = claimed.remove(dest_rel) else {
806                return;
807            };
808            // Compares staging directories, not whole claims: two claims are
809            // never equal (their ids differ by construction), and the question
810            // here is whether anyone else still stages through this directory.
811            if !claimed.values().any(|other| other.staging == staging) {
812                std::fs::remove_dir(&staging).ok();
813            }
814        }
815    }
816}
817
818impl std::fmt::Debug for UploadStore {
819    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
820        f.debug_struct("UploadStore")
821            .field("chunk_size", &self.chunk_size)
822            .finish_non_exhaustive()
823    }
824}
825
826fn poisoned() -> UploadError {
827    UploadError::Io {
828        detail: "internal lock poisoned".to_string(),
829        raw_os_error: None,
830    }
831}
832
833/// Remove staging files left behind by a previous run.
834///
835/// Sessions do not survive a restart, so any `.part` still present is
836/// unreachable — nothing can resume it and nothing will complete it.
837///
838/// Returns `(upload_id, bytes)` for each file removed, so a caller can record
839/// a terminal audit event per orphan — same reason `UploadStore::sweep` and
840/// `cancel` return what they do, rather than dropping what they find. Two
841/// things are recoverable here and one is not: `upload_id` is the filename
842/// stem (`up-{serial:016x}`, never caller input, so parsing it back out is
843/// safe), and `bytes` is the file's size, which always equals what
844/// `append` had written — but the *destination* lived only in the in-memory
845/// `Session` a restart already discarded before this function ever runs, so
846/// there is nothing here to recover it from. See `AuditEvent::with_upload_id`
847/// for how a caller correlates this back to the `upload.start` that does
848/// have it.
849///
850/// An empty `Vec` covers both "nothing to sweep" and "the staging directory
851/// could not be read at all" (most commonly: no upload has ever run against
852/// this root, so it was never created). Not distinguished, same reasoning as
853/// before this was widened: nothing consumes that distinction.
854///
855/// **Machine-wide scope sweeps nothing here, and that is a real gap rather
856/// than an oversight.** With no `--fs-root`, staging follows each destination
857/// to its own directory (see [`UploadStore::staging_dir`]), so the set of
858/// places a `.part` could be left is every directory anyone has ever uploaded
859/// to — not enumerable without walking every drive, which is not a thing a
860/// startup path should do. What covers it instead is
861/// [`sweep_orphan_parts_in`], called against a single destination's staging
862/// directory when an upload next targets it. The practical difference: inside
863/// a jail an orphan is reclaimed at the next restart; machine-wide it is
864/// reclaimed the next time something uploads to the same directory. Both are
865/// invisible to `list`, which refuses the staging directory by name either
866/// way.
867pub fn sweep_orphan_parts(root: &crate::fs::FsRoot) -> Vec<(String, u64)> {
868    let Some(jail) = root.jail_path() else {
869        return Vec::new();
870    };
871    // No age floor: this runs at startup and on an interval against a jail's
872    // single staging directory, where "a `.part` exists" already implies no
873    // session owns it — sessions do not survive a restart, and the interval
874    // caller sweeps expired sessions first.
875    sweep_orphan_parts_in(&jail.join(UPLOAD_DIR), Duration::ZERO)
876}
877
878/// [`sweep_orphan_parts`] against one staging directory, removing only files
879/// that have been untouched for at least `min_age`.
880///
881/// Split out so machine-wide uploads have a reclaim path at all: the caller
882/// that knows a destination knows its staging directory, even though no
883/// startup path can enumerate every such directory.
884///
885/// **`min_age` is what keeps this from destroying a live upload, and it is not
886/// optional for a runtime caller.** Machine-wide staging is shared by every
887/// upload heading for the same directory, so a sweep run when a second session
888/// is created will see the *first* session's `.part` — a file that is very much
889/// owned. Removing it does not fail the writes that follow: the session holds
890/// an open handle, so `append` keeps succeeding against a name that no longer
891/// exists, every chunk answers 200, and only `complete` fails, with
892/// `ENOENT` — after the client has uploaded the whole file. That shape (accept
893/// everything, then lose it at publication) is the worst available, and it is
894/// what an unconditional sweep here produced.
895///
896/// A live session's file is protected because writing to it updates its mtime,
897/// and a session that has gone quiet for longer than the caller's floor has
898/// already been reclaimed by `sweep_expired_uploads`, which the API layer runs
899/// first. An orphan from a previous run has no such protection, which is the
900/// point.
901pub fn sweep_orphan_parts_in(staging: &Path, min_age: Duration) -> Vec<(String, u64)> {
902    let Ok(entries) = std::fs::read_dir(staging) else {
903        return Vec::new();
904    };
905    let mut removed = Vec::new();
906    for entry in entries.flatten() {
907        let path = entry.path();
908        if path.extension().and_then(|e| e.to_str()) != Some("part") {
909            continue;
910        }
911        // Read before removing: there is no size to report once the file is
912        // gone. `std::fs::metadata(&path)` — a fresh stat — rather than the
913        // cheaper `entry.metadata()`: on Windows, `DirEntry::metadata()`
914        // returns the `WIN32_FIND_DATA` captured by the `read_dir`
915        // enumeration itself, which can under-report the size of a file
916        // still open elsewhere for writing (verified: a session whose
917        // staging file was just appended to and never closed reported `0`
918        // bytes here, on this platform, until this was changed to a fresh
919        // stat). A second, different way the same `DirEntry` API is not what
920        // it appears to be: `list`'s own walk (`src/api/fs.rs`) already notes
921        // that `DirEntry::metadata` is lstat-like there, so a symlink looks
922        // in-root when `metadata` would follow it out — that one is about
923        // *which* file the metadata describes, this one is about *how current*
924        // it is, but both come from trusting the enumeration's cached view
925        // instead of asking the filesystem again. Not reachable in
926        // production here — the whole reason a `.part` file is orphaned is
927        // that the process that held it open is gone — but a test exercising
928        // this without a real restart can still hit it, and the fresh call
929        // costs one extra syscall per file, on a path that runs once at
930        // startup.
931        let meta = std::fs::metadata(&path);
932        // Age is read from the same fresh stat, and a file whose age cannot be
933        // established is left alone rather than removed: this is the guard that
934        // stands between a runtime sweep and a live upload, and a guard that
935        // fails open is not one. `Duration::ZERO` makes it a no-op for the
936        // startup caller, where nothing can be live.
937        if !min_age.is_zero() {
938            // `map_or(true, ..)` rather than `is_none_or`: the latter is stable
939            // since 1.82 and this crate's MSRV is 1.78. Same meaning — an
940            // unreadable or unknowable age counts as young, so the file stays.
941            let young_or_unknown = meta
942                .as_ref()
943                .ok()
944                .and_then(|m| m.modified().ok())
945                .and_then(|modified| modified.elapsed().ok())
946                .map_or(true, |age| age < min_age);
947            if young_or_unknown {
948                continue;
949            }
950        }
951        let bytes = meta.map(|m| m.len()).unwrap_or(0);
952        let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
953            // Not a name this process ever generated (`up-{serial:016x}.part`
954            // is always valid UTF-8) — nothing to correlate an event to, so
955            // the file is removed but not reported.
956            std::fs::remove_file(&path).ok();
957            continue;
958        };
959        let id = id.to_string();
960        if std::fs::remove_file(&path).is_ok() {
961            removed.push((id, bytes));
962        }
963    }
964    removed
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use crate::fs::FsRoot;
971
972    fn store() -> (tempfile::TempDir, FsRoot, UploadStore) {
973        let dir = tempfile::tempdir().expect("tempdir");
974        let root = FsRoot::new(dir.path()).expect("root");
975        let store = UploadStore::new(DEFAULT_CHUNK_SIZE);
976        (dir, root, store)
977    }
978
979    impl UploadStore {
980        /// `create` from a root-relative destination, resolving it the way the
981        /// API layer does.
982        ///
983        /// `create` takes the resolved absolute destination because staging
984        /// has to land on the destination's own filesystem when no `--fs-root`
985        /// narrows the scope (see `staging_dir`). These tests all run against a
986        /// jail, where that resolution is uninteresting — doing it here rather
987        /// than passing some hand-built path keeps them exercising the same
988        /// path the real caller takes.
989        fn create_rel(
990            &self,
991            root: &FsRoot,
992            dest: &str,
993            size: u64,
994            sha256: String,
995        ) -> Result<String, UploadError> {
996            let absolute = root.resolve_for_create(dest).expect("destination resolves");
997            self.create(root, &absolute, dest.to_string(), size, sha256)
998        }
999    }
1000
1001    /// The staging directory of a jailed root.
1002    ///
1003    /// `staging_dir` takes a destination because machine-wide scope has to put
1004    /// staging on the destination's own filesystem. A jail ignores it, so these
1005    /// tests name that explicitly rather than threading a value none of them
1006    /// care about through every call.
1007    fn staging_of(root: &FsRoot) -> PathBuf {
1008        UploadStore::staging_dir(root, Path::new("ignored-when-jailed"))
1009    }
1010
1011    /// SHA-256 of b"hello world".
1012    const HELLO_DIGEST: &str = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
1013
1014    /// A store built with the relay size must actually refuse a chunk above
1015    /// it. The constant is only worth anything if `append` enforces it — the
1016    /// advertised number and the enforced ceiling are the same field, and a
1017    /// change that advertised one while enforcing the other would leave the
1018    /// caller obeying a limit that does nothing.
1019    #[test]
1020    fn a_store_at_the_relay_size_enforces_it() {
1021        let dir = tempfile::tempdir().expect("tempdir");
1022        let root = FsRoot::new(dir.path()).expect("root");
1023        let store = UploadStore::new(RELAY_CHUNK_SIZE);
1024        assert_eq!(store.chunk_size(), RELAY_CHUNK_SIZE);
1025
1026        let id = store
1027            .create_rel(
1028                &root,
1029                "out.bin",
1030                DEFAULT_CHUNK_SIZE as u64,
1031                HELLO_DIGEST.into(),
1032            )
1033            .expect("create");
1034        let oversized = vec![0_u8; RELAY_CHUNK_SIZE + 1];
1035        assert_eq!(store.append(&id, 0, &oversized), Err(UploadError::TooLarge));
1036    }
1037
1038    /// 트리 삭제가 진행 중인 업로드를 지우지 않으려면, 어떤 디렉터리 아래에
1039    /// 살아있는 세션의 스테이징 파일이 있는지 물을 수 있어야 한다. 고아
1040    /// `.part`는 "살아있음"이 아니다 — 그것까지 살아있다고 답하면 스윕이 늦은
1041    /// 트리가 무기한 삭제 불가가 된다.
1042    #[test]
1043    fn a_live_session_is_visible_under_its_staging_directory() {
1044        let (dir, root, store) = store();
1045        let staging = staging_of(&root);
1046
1047        assert!(
1048            !store.has_live_part_under(dir.path()),
1049            "세션이 없으면 아무것도 살아있지 않다"
1050        );
1051
1052        let id = store
1053            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1054            .expect("create");
1055
1056        assert!(
1057            store.has_live_part_under(dir.path()),
1058            "루트 아래에서 보인다"
1059        );
1060        assert!(
1061            store.has_live_part_under(&staging),
1062            "스테이징 자신 아래에서도 보인다"
1063        );
1064        // 캐노니칼화할 수 있도록 존재하는 디렉터리 생성
1065        let elsewhere = dir.path().join("elsewhere");
1066        std::fs::create_dir_all(&elsewhere).expect("mkdir elsewhere");
1067        assert!(
1068            !store.has_live_part_under(&elsewhere),
1069            "관계없는 디렉터리 아래에서는 보이지 않는다"
1070        );
1071
1072        store.cancel(&id);
1073        assert!(
1074            !store.has_live_part_under(dir.path()),
1075            "취소된 세션은 살아있지 않다"
1076        );
1077    }
1078
1079    /// 세션이 없는 채 남은 `.part`(이전 실행의 고아)는 살아있지 않다.
1080    /// 이 테스트는 고아 `.part` 파일이 디스크에 존재해도, 세션 목록에
1081    /// 없으면 "살아있음"이 아님을 증명한다. 구현이 디스크를 조회한다면
1082    /// 이 테스트는 실패할 것이다.
1083    #[test]
1084    fn an_orphan_part_file_is_not_a_live_session() {
1085        let (dir, root, store) = store();
1086        let staging = staging_of(&root);
1087        std::fs::create_dir_all(&staging).expect("mkdir staging");
1088
1089        // 살아있는 세션을 생성
1090        let live_id = store
1091            .create_rel(&root, "upload1.bin", 5, "0".repeat(64))
1092            .expect("create live session");
1093
1094        // 세션이 있으므로 true를 반환
1095        assert!(
1096            store.has_live_part_under(dir.path()),
1097            "살아있는 세션이 있으므로 true를 반환한다"
1098        );
1099
1100        // 세션을 취소하고, 고아 `.part` 파일을 그 자리에 남김.
1101        // Cancelling now reclaims the staging directory as well, so it has to
1102        // be recreated before an orphan can be planted in it — which is also
1103        // the shape of the real case: a previous run's directory, remade by
1104        // whichever upload comes next.
1105        store.cancel(&live_id);
1106        std::fs::create_dir_all(&staging).expect("remake staging");
1107        let orphan_path = staging.join("up-0000000000000000.part");
1108        std::fs::write(&orphan_path, b"orphan content").expect("write orphan");
1109        assert!(
1110            orphan_path.exists(),
1111            "고아 파일이 디스크에 실제로 존재해야 함"
1112        );
1113
1114        // 고아 파일이 있어도 세션 목록에 없으므로 false를 반환해야 한다.
1115        // 구현이 디스크를 조회한다면 이 assertion이 실패할 것이다.
1116        assert!(
1117            !store.has_live_part_under(dir.path()),
1118            "고아 파일이 있어도 세션 목록이 기준이므로 false를 반환한다"
1119        );
1120    }
1121
1122    /// 존재하지 않는 경로(캐노니칼화 불가)에 대한 조회는 안전하게 "있다"고 답한다.
1123    /// `part_path`는 항상 정규화된 절대경로이므로, 정규화되지 않은 경로와는
1124    /// 비교할 수 없다. 알 수 없는 경우 삭제를 거부하는 쪽이 안전하다.
1125    #[test]
1126    fn a_nonexistent_path_cannot_be_canonicalized_so_answers_true() {
1127        let (dir, root, store) = store();
1128        let id = store
1129            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1130            .expect("create");
1131
1132        // 존재하지 않는 경로: canonicalize가 실패한다
1133        let nonexistent = dir.path().join("does-not-exist");
1134        assert!(!nonexistent.exists(), "path must not exist for this test");
1135
1136        // 캐노니칼화할 수 없으므로 안전하게 "있다"고 답해야 한다
1137        assert!(
1138            store.has_live_part_under(&nonexistent),
1139            "캐노니칼화 불가능한 경로는 안전하게 true를 반환한다"
1140        );
1141
1142        store.cancel(&id);
1143    }
1144
1145    #[test]
1146    fn a_session_starts_at_offset_zero() {
1147        let (_dir, root, store) = store();
1148        let id = store
1149            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1150            .expect("create");
1151        assert_eq!(store.offset(&id), Some(0));
1152    }
1153
1154    #[test]
1155    fn chunks_advance_the_offset() {
1156        let (_dir, root, store) = store();
1157        let id = store
1158            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1159            .expect("create");
1160
1161        assert_eq!(store.append(&id, 0, b"hello ").expect("first"), 6);
1162        assert_eq!(store.append(&id, 6, b"world").expect("second"), 11);
1163    }
1164
1165    #[test]
1166    fn a_chunk_at_the_wrong_offset_is_refused_with_the_expected_one() {
1167        let (_dir, root, store) = store();
1168        let id = store
1169            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1170            .expect("create");
1171        store.append(&id, 0, b"hello ").expect("first");
1172
1173        assert_eq!(
1174            store.append(&id, 0, b"again"),
1175            Err(UploadError::OffsetMismatch { expected: 6 })
1176        );
1177    }
1178
1179    #[test]
1180    fn two_sessions_may_not_target_the_same_path() {
1181        let (_dir, root, store) = store();
1182        let first = store
1183            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1184            .expect("first");
1185        // The refusal must name the session that caused it — that id is the
1186        // only route back to the in-flight transfer, and a refusal without it
1187        // leaves the destination unreachable until the TTL expires.
1188        assert_eq!(
1189            store.create_rel(&root, "out.bin", 11, HELLO_DIGEST.into()),
1190            Err(UploadError::Conflict { upload_id: first })
1191        );
1192    }
1193
1194    /// The id in a `Conflict` has to be the *live holder's*, not the id the
1195    /// refused attempt minted for itself. Those are different values (the
1196    /// counter advances before the claim is checked), and reporting the
1197    /// caller's own id back to it would be worse than reporting nothing: it
1198    /// names a session that does not exist, so `GET` answers 404 and the
1199    /// caller concludes the destination is stuck.
1200    #[test]
1201    fn a_conflict_names_the_holder_not_the_refused_caller() {
1202        let (_dir, root, store) = store();
1203        let holder = store
1204            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1205            .expect("first");
1206
1207        match store.create_rel(&root, "out.bin", 11, HELLO_DIGEST.into()) {
1208            Err(UploadError::Conflict { upload_id }) => {
1209                assert_eq!(upload_id, holder);
1210                // And it is a *live* session: the id the refusal handed back
1211                // must actually resolve, or it is not a route to anything.
1212                assert_eq!(store.offset(&upload_id), Some(0));
1213            }
1214            other => panic!("expected a conflict naming the holder, got {other:?}"),
1215        }
1216    }
1217
1218    /// Ids are minted before the claim is taken, so a refused `create`
1219    /// consumes a serial and ids are not contiguous. Pinned deliberately: a
1220    /// consumer already tried to enumerate ids to find a live session, and
1221    /// this is half of why that could never work (the other half being that
1222    /// they are hex). Nothing in this crate depends on contiguity — this test
1223    /// exists so that a future change assuming it fails here rather than in
1224    /// somebody's deploy script.
1225    #[test]
1226    fn a_refused_create_still_consumes_a_serial() {
1227        let (_dir, root, store) = store();
1228        let first = store
1229            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1230            .expect("first");
1231        assert_eq!(first, "up-0000000000000000");
1232
1233        // Refused: consumes serial 1.
1234        assert!(store
1235            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1236            .is_err());
1237
1238        let next = store
1239            .create_rel(&root, "other.bin", 11, HELLO_DIGEST.into())
1240            .expect("second destination");
1241        assert_eq!(
1242            next, "up-0000000000000002",
1243            "the refused attempt's serial is not reused — ids are not a dense sequence"
1244        );
1245    }
1246
1247    #[test]
1248    fn a_matching_checksum_completes() {
1249        let (_dir, root, store) = store();
1250        let id = store
1251            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1252            .expect("create");
1253        store.append(&id, 0, b"hello world").expect("append");
1254
1255        let finished = store.take_for_complete(&id).expect("complete");
1256        assert_eq!(finished.bytes, 11);
1257        assert_eq!(finished.digest, HELLO_DIGEST);
1258        assert_eq!(finished.dest_rel, "out.bin");
1259    }
1260
1261    #[test]
1262    fn a_mismatched_checksum_is_refused() {
1263        let (_dir, root, store) = store();
1264        let wrong = "0".repeat(64);
1265        let id = store
1266            .create_rel(&root, "out.bin", 11, wrong.clone())
1267            .expect("create");
1268        store.append(&id, 0, b"hello world").expect("append");
1269
1270        match store.take_for_complete(&id) {
1271            Err(UploadError::Checksum {
1272                expected,
1273                actual,
1274                dest_rel,
1275            }) => {
1276                assert_eq!(expected, wrong);
1277                assert_eq!(actual, HELLO_DIGEST);
1278                assert_eq!(dest_rel, "out.bin");
1279            }
1280            other => panic!("expected a checksum refusal, got {other:?}"),
1281        }
1282        // The session is gone and the staging file with it.
1283        assert_eq!(store.offset(&id), None);
1284    }
1285
1286    #[test]
1287    fn a_chunk_above_the_ceiling_is_refused() {
1288        let (_dir, root, store) = store();
1289        let id = store
1290            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1291            .expect("create");
1292        let oversized = vec![0_u8; DEFAULT_CHUNK_SIZE + 1];
1293        assert_eq!(store.append(&id, 0, &oversized), Err(UploadError::TooLarge));
1294    }
1295
1296    #[test]
1297    fn a_chunk_that_would_exceed_the_declared_size_is_refused() {
1298        let (_dir, root, store) = store();
1299        // Declares 5 bytes; the digest is irrelevant here since the size
1300        // check runs at `append` time, well before any checksum comparison.
1301        let id = store
1302            .create_rel(&root, "out.bin", 5, HELLO_DIGEST.into())
1303            .expect("create");
1304        assert_eq!(
1305            store.append(&id, 0, b"hello world"),
1306            Err(UploadError::SizeExceeded)
1307        );
1308        // Refused before anything was written: the offset must not have moved.
1309        assert_eq!(store.offset(&id), Some(0));
1310    }
1311
1312    #[test]
1313    fn a_chunk_landing_exactly_on_the_declared_size_is_accepted() {
1314        let (_dir, root, store) = store();
1315        let id = store
1316            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1317            .expect("create");
1318        // Exactly 11 bytes against a declared size of 11 — the boundary
1319        // `a_chunk_that_would_exceed_the_declared_size_is_refused` does not
1320        // cover, and the one `>` (not `>=`) in the check depends on.
1321        assert_eq!(store.append(&id, 0, b"hello world").expect("append"), 11);
1322    }
1323
1324    #[test]
1325    fn cancelling_removes_the_session_and_frees_the_destination() {
1326        let (_dir, root, store) = store();
1327        let id = store
1328            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1329            .expect("create");
1330        store.append(&id, 0, b"hello ").expect("append");
1331
1332        let (destination, bytes) = store.cancel(&id).expect("session existed");
1333        assert_eq!(destination, "out.bin");
1334        assert_eq!(bytes, 6);
1335        assert_eq!(store.offset(&id), None);
1336        // The destination is claimable again.
1337        assert!(store
1338            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1339            .is_ok());
1340    }
1341
1342    #[test]
1343    fn sweeping_drops_sessions_past_their_ttl() {
1344        let (_dir, root, store) = store();
1345        let staging = root.jail_path().expect("jailed").join(UPLOAD_DIR);
1346        let id = store
1347            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1348            .expect("create");
1349        assert!(staging.is_dir(), "staging exists while the session is live");
1350
1351        assert_eq!(store.sweep(Duration::ZERO).len(), 1);
1352        assert_eq!(store.offset(&id), None);
1353        // The sweep path reclaims the directory too, and it only can because
1354        // it removes the staging file *before* releasing the claim: `release`
1355        // reclaims under the claim lock, and `remove_dir` refuses a directory
1356        // that still holds a `.part`. This asserts that ordering from the
1357        // outside, where reversing it leaves an empty directory behind.
1358        assert!(
1359            !staging.exists(),
1360            "a swept session must not leave its staging directory behind"
1361        );
1362    }
1363
1364    /// `create` used to sweep opportunistically (with the real, fixed
1365    /// `SESSION_TTL`) before doing anything else. That call is gone — moved
1366    /// to the caller, which sweeps through the audit-aware
1367    /// `sweep_expired_uploads` instead (`src/api/fs.rs`) — because
1368    /// `UploadStore` has no `AuditSink` to record with, so a sweep run from
1369    /// inside this method could never leave a trail. This is the regression
1370    /// guard for that move: even a session that a zero-TTL sweep would call
1371    /// stale must survive an unrelated `create` call untouched, proving
1372    /// `create` itself no longer reclaims anything — only an explicit
1373    /// `sweep`/`sweep_expired_uploads` call does.
1374    #[test]
1375    fn create_does_not_sweep_expired_sessions_itself() {
1376        let (_dir, root, store) = store();
1377        let id = store
1378            .create_rel(&root, "old.bin", 11, HELLO_DIGEST.into())
1379            .expect("create");
1380
1381        store
1382            .create_rel(&root, "new.bin", 11, HELLO_DIGEST.into())
1383            .expect("second create");
1384
1385        assert_eq!(
1386            store.offset(&id),
1387            Some(0),
1388            "create must not silently reclaim a stale session; only an explicit sweep call may"
1389        );
1390    }
1391
1392    /// The strongest test in this module: `create` opens the staging file
1393    /// with `create_new`, which must fail (`EEXIST`) rather than follow an
1394    /// existing symlink at that exact name. Planted *before* any session
1395    /// exists, exploiting that a fresh store's counter starts at 0 — so the
1396    /// first session's id, and therefore its staging path, is predictable
1397    /// (`up-0000000000000000.part`).
1398    ///
1399    /// Two assertions, not one: the create must fail, *and* the outside
1400    /// target must be untouched. Checking only the error would still pass a
1401    /// version that wrote through the link and then failed for an unrelated
1402    /// reason afterward.
1403    #[test]
1404    fn a_pre_existing_symlink_at_the_predicted_staging_path_cannot_be_written_through() {
1405        let outer = tempfile::tempdir().expect("outer tempdir");
1406        let root_dir = outer.path().join("root");
1407        std::fs::create_dir_all(&root_dir).expect("mkdir root");
1408        let root = FsRoot::new(&root_dir).expect("root");
1409        let store = UploadStore::new(DEFAULT_CHUNK_SIZE);
1410
1411        let secret = outer.path().join("secret.txt");
1412        std::fs::write(&secret, b"outside-secret").expect("write secret");
1413
1414        let staging = staging_of(&root);
1415        std::fs::create_dir_all(&staging).expect("mkdir staging");
1416        let predicted = staging.join("up-0000000000000000.part");
1417
1418        #[cfg(unix)]
1419        let linked = std::os::unix::fs::symlink(&secret, &predicted).is_ok();
1420        #[cfg(windows)]
1421        let linked = std::os::windows::fs::symlink_file(&secret, &predicted).is_ok();
1422        #[cfg(not(any(unix, windows)))]
1423        let linked = false;
1424        if !linked {
1425            return; // symlink privilege unavailable on this runner; skip
1426        }
1427
1428        let result = store.create_rel(&root, "app-new.bin", 11, HELLO_DIGEST.into());
1429        assert!(
1430            matches!(result, Err(UploadError::Io { .. })),
1431            "create_new must refuse a pre-existing symlink at the staging path \
1432             rather than follow it, got {result:?}"
1433        );
1434        assert_eq!(
1435            std::fs::read(&secret).expect("read secret"),
1436            b"outside-secret",
1437            "the outside target must be untouched: the open must fail before \
1438             any write reaches it"
1439        );
1440    }
1441
1442    #[test]
1443    fn a_cap_limits_concurrent_sessions_and_releasing_one_frees_a_slot() {
1444        let (_dir, root, store) = store();
1445        let mut ids = Vec::with_capacity(MAX_CONCURRENT_UPLOADS);
1446        for i in 0..MAX_CONCURRENT_UPLOADS {
1447            let id = store
1448                .create_rel(&root, &format!("f{i}.bin"), 1, HELLO_DIGEST.into())
1449                .unwrap_or_else(|e| panic!("session {i} should fit under the cap: {e:?}"));
1450            ids.push(id);
1451        }
1452
1453        assert_eq!(
1454            store.create_rel(&root, "one-too-many.bin", 1, HELLO_DIGEST.into()),
1455            Err(UploadError::TooManySessions)
1456        );
1457
1458        // Freeing one slot makes room for exactly one more.
1459        assert!(store.cancel(&ids[0]).is_some());
1460        assert!(store
1461            .create_rel(&root, "one-too-many.bin", 1, HELLO_DIGEST.into())
1462            .is_ok());
1463    }
1464
1465    #[test]
1466    fn completing_an_upload_keeps_the_destination_claimed_until_explicitly_released() {
1467        let (_dir, root, store) = store();
1468        let id = store
1469            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1470            .expect("create");
1471        store.append(&id, 0, b"hello world").expect("append");
1472        let finished = store.take_for_complete(&id).expect("complete");
1473
1474        // The caller has not renamed the staging file into place yet (has not
1475        // called `release_destination`), so the destination must still be
1476        // refused to a second session — otherwise two sessions could both be
1477        // mid-publication to the same path.
1478        //
1479        // The claim still names the completed session's id. That is the
1480        // honest answer even though `sessions` no longer holds it: the id
1481        // identifies what is mid-publication to this path, and a caller
1482        // retrying here is meant to back off, not to resume — `GET` on that
1483        // id answers 404, which says "not resumable" rather than "unknown
1484        // destination".
1485        assert_eq!(
1486            store.create_rel(&root, "out.bin", 11, HELLO_DIGEST.into()),
1487            Err(UploadError::Conflict {
1488                upload_id: id.clone()
1489            })
1490        );
1491
1492        store.release_destination(&finished.dest_rel);
1493
1494        // Now that the caller is done with it, the destination is claimable again.
1495        assert!(store
1496            .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1497            .is_ok());
1498    }
1499
1500    #[test]
1501    fn sweep_orphan_parts_removes_leftover_part_files_and_nothing_else() {
1502        let dir = tempfile::tempdir().expect("tempdir");
1503        let root = FsRoot::new(dir.path()).expect("root");
1504        let staging = staging_of(&root);
1505        std::fs::create_dir_all(&staging).expect("mkdir staging");
1506        std::fs::write(staging.join("up-0000000000000000.part"), b"leftover")
1507            .expect("write orphan");
1508        std::fs::write(staging.join("up-0000000000000001.part"), b"leftover2")
1509            .expect("write second orphan");
1510        // Not a `.part` file — proves the extension filter, not "delete
1511        // everything in the directory".
1512        std::fs::write(staging.join("keep.txt"), b"not a part file").expect("write keep");
1513
1514        let mut removed = sweep_orphan_parts(&root);
1515        removed.sort();
1516        assert_eq!(
1517            removed,
1518            vec![
1519                ("up-0000000000000000".to_string(), 8),
1520                ("up-0000000000000001".to_string(), 9),
1521            ],
1522            "each orphan must be reported by its id (the filename stem) and the bytes it held, so a caller can audit it"
1523        );
1524        assert!(!staging.join("up-0000000000000000.part").exists());
1525        assert!(!staging.join("up-0000000000000001.part").exists());
1526        assert!(
1527            staging.join("keep.txt").exists(),
1528            "only .part files are orphans; anything else in staging must survive"
1529        );
1530    }
1531
1532    #[test]
1533    fn a_poisoned_sessions_lock_does_not_leak_the_claim_or_the_staging_file() {
1534        let (_dir, root, store) = store();
1535
1536        // Poison `sessions` by panicking while holding its write guard.
1537        // `catch_unwind` keeps the panic from taking the test process down;
1538        // the guard's `Drop` still runs during the unwind and marks the
1539        // lock poisoned regardless of the panic being caught afterward.
1540        let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1541            let _guard = store.sessions.write().expect("lock not yet poisoned");
1542            panic!("poison it");
1543        }));
1544        assert!(
1545            poisoned.is_err(),
1546            "the closure must have panicked while holding the write guard"
1547        );
1548
1549        let outcome = store.create_rel(&root, "out.bin", 11, HELLO_DIGEST.into());
1550        assert!(
1551            matches!(outcome, Err(UploadError::Io { .. })),
1552            "a poisoned sessions lock must surface as an Io error, got {outcome:?}"
1553        );
1554
1555        // Recover the lock — a real caller cannot do this, but the test does,
1556        // purely to inspect whether the failed attempt above left anything
1557        // behind. If it did, this second `create` for the same destination
1558        // would come back `Err(Conflict)` instead of succeeding.
1559        store.sessions.clear_poison();
1560        assert!(
1561            store
1562                .create_rel(&root, "out.bin", 11, HELLO_DIGEST.into())
1563                .is_ok(),
1564            "the destination must not still be claimed by the failed attempt"
1565        );
1566
1567        let staging = staging_of(&root);
1568        let leftover_parts = std::fs::read_dir(&staging)
1569            .expect("staging dir")
1570            .flatten()
1571            .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("part"))
1572            .count();
1573        assert_eq!(
1574            leftover_parts, 1,
1575            "only the second, successful session's staging file should remain"
1576        );
1577    }
1578}