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