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