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}