trusty_console/webhook/spool.rs
1//! The durable spool: a delivery is on disk and fsync'd before console
2//! acknowledges GitHub.
3//!
4//! Why: both existing webhook handlers return `202` and *then* do the work, so
5//! any failure after the ack loses the delivery permanently โ GitHub does not
6//! retry an acknowledged delivery (ADR-0034 Context, "The fail-open shape,
7//! already shipped on this exact path"). Ordering the durable write before the
8//! ack is what makes a lost delivery either recoverable from GitHub (never
9//! acked) or visible in a health signal (acked and spooled). That ordering only
10//! holds if the write is genuinely durable, hence the fsync of both the file
11//! and its directory.
12//!
13//! What: one JSON file per delivery under
14//! `resolve_data_dir("trusty-console")/webhook-spool/`. [`Spool::persist_new`]
15//! writes a temp file, `fsync`s it, links it into place โ refusing to clobber an
16//! entry already there โ then `fsync`s the directory so the new name survives a
17//! crash. [`Spool::persist_update`] is the same sequence committing with
18//! `rename`, for [`Spool::record_attempt`]'s deliberate overwrite.
19//! [`Spool::remove_acked`] is the only deletion path and is reachable only from
20//! an explicit target acknowledgement.
21//!
22//! ๐ด [`Spool::list_pending`] answers a missing directory with an error, not an
23//! empty listing, for any spool that was successfully opened. An unreadable
24//! spool reported as "nothing pending" makes the health scan green while every
25//! delivery 500s.
26//!
27//! ๐ด Every fallible step here returns an error to the caller. Nothing in this
28//! module logs-and-continues, because a swallowed spool failure is exactly the
29//! defect the spool exists to remove.
30//!
31//! Test: `webhook/tests.rs` โ `spool_*` cases cover the durable round trip,
32//! attempt bumping, ack-only deletion, and both write-failure arms.
33
34use std::collections::BTreeMap;
35use std::fs::{File, Permissions};
36use std::io::Write;
37use std::os::unix::fs::PermissionsExt;
38use std::path::{Path, PathBuf};
39
40use serde::{Deserialize, Serialize};
41
42/// Mode the spool directory is held at.
43///
44/// A spooled delivery holds the raw webhook body, which is not public data.
45/// Owner-only, matching the `0700` convention `trusty_common::uds` established
46/// for the socket directory.
47const SPOOL_DIR_MODE: u32 = 0o700;
48
49/// Mode every spool entry file is written at.
50const SPOOL_FILE_MODE: u32 = 0o600;
51
52/// Bumped whenever [`SpoolEntry`]'s shape changes, so a future reader can tell
53/// an entry it cannot interpret from one it can.
54pub const SPOOL_SCHEMA_VERSION: u32 = 1;
55
56/// Directory name under the console's data dir.
57pub const SPOOL_DIR_NAME: &str = "webhook-spool";
58
59/// Subdirectory holding deliveries console has stopped trying to relay.
60///
61/// Why: an entry past `max_attempts` is never relayed again, but deleting it
62/// would discard a delivery GitHub will never re-send โ so it is moved aside
63/// rather than removed. Moving it matters as much as keeping it: while it sat
64/// in the live directory, every sweep and every metrics request read and
65/// JSON-decoded it, forever, and it pinned the oldest-pending diagnostics to
66/// itself so a genuinely new failure changed nothing an operator reads.
67pub const EXHAUSTED_DIR_NAME: &str = "exhausted";
68
69/// Failures of the durable-write path. Every variant means the delivery is
70/// **not** safely recorded, so every one of them must reach the HTTP caller as
71/// a 5xx rather than a log line.
72#[derive(Debug, thiserror::Error)]
73#[non_exhaustive]
74pub enum SpoolError {
75 /// The spool directory could not be created or hardened.
76 #[error("prepare spool directory {path}: {source}")]
77 PrepareDir {
78 /// Directory that could not be prepared.
79 path: PathBuf,
80 /// Underlying OS error.
81 #[source]
82 source: std::io::Error,
83 },
84
85 /// Serialising the entry failed.
86 #[error("serialize spool entry {delivery_id}: {source}")]
87 Encode {
88 /// Delivery that could not be encoded.
89 delivery_id: String,
90 /// Underlying serde error.
91 #[source]
92 source: serde_json::Error,
93 },
94
95 /// Writing or fsyncing the temp file failed โ disk full, permission, or a
96 /// genuine fsync error.
97 #[error("write spool entry to {path}: {source}")]
98 Write {
99 /// Temp path that could not be written.
100 path: PathBuf,
101 /// Underlying OS error.
102 #[source]
103 source: std::io::Error,
104 },
105
106 /// The atomic rename into place failed.
107 #[error("commit spool entry {from} -> {to}: {source}")]
108 Commit {
109 /// Temp path.
110 from: PathBuf,
111 /// Final path.
112 to: PathBuf,
113 /// Underlying OS error.
114 #[source]
115 source: std::io::Error,
116 },
117
118 /// The directory fsync that makes the rename durable failed.
119 #[error("fsync spool directory {path}: {source}")]
120 SyncDir {
121 /// Directory that could not be synced.
122 path: PathBuf,
123 /// Underlying OS error.
124 #[source]
125 source: std::io::Error,
126 },
127
128 /// An entry already exists at the path a fresh delivery derives.
129 ///
130 /// Defensive: GitHub always sends `X-GitHub-Delivery`, so two deliveries
131 /// only collide when the header is absent AND they land in the same
132 /// millisecond. Refusing is still the right answer โ clobbering would
133 /// destroy a delivery that has already been acknowledged.
134 #[error("a spool entry already exists at {path}; refusing to clobber it")]
135 AlreadyExists {
136 /// Path that was already taken.
137 path: PathBuf,
138 },
139
140 /// Listing the spool failed. Surfaced as a red health state rather than an
141 /// empty (and therefore falsely healthy) listing.
142 #[error("read spool directory {path}: {source}")]
143 ReadDir {
144 /// Directory that could not be read.
145 path: PathBuf,
146 /// Underlying OS error.
147 #[source]
148 source: std::io::Error,
149 },
150
151 /// Deleting an acknowledged entry failed.
152 #[error("remove acknowledged spool entry {path}: {source}")]
153 Remove {
154 /// Entry that could not be removed.
155 path: PathBuf,
156 /// Underlying OS error.
157 #[source]
158 source: std::io::Error,
159 },
160}
161
162/// What console proves to the target about a relayed body (ADR-0034 ยง3).
163///
164/// Re-exported from `trusty_common::webhook_relay` rather than defined here:
165/// step 4's receivers live in `trusty-review` and `trusty-analyze`, which
166/// cannot depend on the console, so the type has to sit where both halves read
167/// it.
168pub use trusty_common::webhook_relay::Provenance;
169
170/// One spooled delivery.
171///
172/// Why: holds everything a target needs to act on the delivery and everything
173/// an operator needs to diagnose a stuck one, so a pending entry is
174/// self-describing without console being alive to explain it.
175/// What: the raw body is base64 so the JSON container cannot corrupt bytes the
176/// HMAC was computed over; the target decodes it and may re-verify
177/// independently. `attempts` and `last_error` are the durable record of relay
178/// failure that replaces the `tracing::warn!` both current handlers use.
179/// Test: `spool_persists_and_reloads_an_entry_byte_exact`.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct SpoolEntry {
182 /// Schema version; see [`SPOOL_SCHEMA_VERSION`].
183 pub schema_version: u32,
184 /// GitHub's `X-GitHub-Delivery` GUID, or a synthesised stand-in.
185 pub delivery_id: String,
186 /// Which target this delivery is bound for (`review` / `analyze`).
187 pub source: String,
188 /// The `X-GitHub-Event` value.
189 pub event: String,
190 /// The request headers, lowercased, as received.
191 pub headers: BTreeMap<String, String>,
192 /// The raw request body, base64-encoded โ byte-exact, never re-serialised.
193 pub body_b64: String,
194 /// What console verified before spooling.
195 pub provenance: Provenance,
196 /// When console accepted the delivery.
197 pub received_at_unix_ms: u64,
198 /// How many relay attempts have failed. `0` on a freshly spooled entry.
199 pub attempts: u32,
200 /// Why the most recent attempt failed, if one has.
201 pub last_error: Option<String>,
202 /// When the most recent attempt ran.
203 pub last_attempt_at_unix_ms: Option<u64>,
204}
205
206/// A pending entry paired with the path it lives at.
207#[derive(Debug, Clone)]
208pub struct PendingEntry {
209 /// Absolute path of the entry file.
210 pub path: PathBuf,
211 /// The decoded entry.
212 pub entry: SpoolEntry,
213}
214
215/// The on-disk spool rooted at one directory.
216///
217/// Why: `trusty_common::resolve_data_dir("trusty-console")` is already the
218/// console's canonical state location (`lib.rs:476`), so the spool is a
219/// subdirectory of it rather than a new location convention.
220/// What: a path plus the durable write sequence. Cheap to clone.
221/// Test: every `spool_*` case in `webhook/tests.rs`.
222#[derive(Debug, Clone)]
223pub struct Spool {
224 root: PathBuf,
225 /// Whether this spool's directory was successfully created at construction.
226 ///
227 /// ๐ด Load-bearing for the health signal, not bookkeeping. Once the
228 /// directory has been created, its later absence means it was *removed* or
229 /// its volume unmounted โ a broken ingress, not an empty one. Without this
230 /// flag `list_pending` cannot tell "never written" from "gone", and
231 /// answering `ErrorKind::NotFound` with an empty listing makes
232 /// `scan_health` report green while every `POST /api/webhooks/{source}`
233 /// 500s. See [`Spool::list_pending`].
234 opened: bool,
235}
236
237impl Spool {
238 /// Bind a spool to `root` without touching the filesystem.
239 ///
240 /// Why: lets a caller construct the spool before deciding whether it can be
241 /// created, and lets a test point one at a path that will fail on write.
242 /// What: stores the path. No I/O, and the spool is NOT marked opened โ an
243 /// absent directory under this constructor is genuinely "never written".
244 /// Test: `spool_persist_fails_when_the_root_is_not_a_directory`.
245 pub fn at(root: impl Into<PathBuf>) -> Self {
246 Self {
247 root: root.into(),
248 opened: false,
249 }
250 }
251
252 /// Bind a spool to `root`, creating it at `0700`.
253 ///
254 /// What: creates the directory and records that it existed, which is what
255 /// makes a later `ENOENT` a failure rather than an empty listing.
256 /// Test: `spool_open_creates_the_directory_at_0700`,
257 /// `health_reports_error_when_the_spool_directory_is_gone`.
258 pub fn open(root: impl Into<PathBuf>) -> Result<Self, SpoolError> {
259 let mut spool = Self::at(root);
260 spool.prepare_dir()?;
261 spool.opened = true;
262 Ok(spool)
263 }
264
265 /// The console's production spool location.
266 ///
267 /// Test: exercised indirectly by `WebhookIngress::from_env`.
268 pub fn default_root() -> anyhow::Result<PathBuf> {
269 Ok(trusty_common::resolve_data_dir("trusty-console")?.join(SPOOL_DIR_NAME))
270 }
271
272 /// Directory this spool writes into.
273 pub fn root(&self) -> &Path {
274 &self.root
275 }
276
277 fn prepare_dir(&self) -> Result<(), SpoolError> {
278 std::fs::create_dir_all(&self.root).map_err(|source| SpoolError::PrepareDir {
279 path: self.root.clone(),
280 source,
281 })?;
282 std::fs::set_permissions(&self.root, Permissions::from_mode(SPOOL_DIR_MODE)).map_err(
283 |source| SpoolError::PrepareDir {
284 path: self.root.clone(),
285 source,
286 },
287 )
288 }
289
290 /// Path an entry occupies, derived from its receipt time and delivery id.
291 ///
292 /// Why: the leading zero-padded millisecond timestamp makes a lexical sort
293 /// an age sort, so the oldest-pending scan does not have to parse every
294 /// file to find the oldest one. Public because [`Spool::record_attempt`]
295 /// rewrites the same path and the tests assert on it.
296 /// What: `<received_at_unix_ms:013>-<sanitised delivery id>.json`. The id is
297 /// reduced to `[A-Za-z0-9_-]` and truncated so a hostile header value
298 /// cannot traverse out of the spool directory or overflow `NAME_MAX`.
299 /// Test: `spool_entry_path_sanitises_a_hostile_delivery_id`.
300 pub fn entry_path(&self, entry: &SpoolEntry) -> PathBuf {
301 self.root.join(format!(
302 "{:013}-{}.json",
303 entry.received_at_unix_ms,
304 sanitise_delivery_id(&entry.delivery_id)
305 ))
306 }
307
308 /// Write a NEW entry durably, refusing to overwrite an existing one.
309 ///
310 /// Why: this call returning `Ok` is the ONLY thing that licenses console to
311 /// send GitHub a `202`. ADR-0034 ยง2: "Console returns `202` **only after**
312 /// the delivery โฆ is written and fsync'd to a spool." Refusing to clobber
313 /// matters because the entry already at that path may be a delivery console
314 /// has already acknowledged; overwriting it would destroy work GitHub will
315 /// never re-send. (Defensive: GitHub always sends `X-GitHub-Delivery`, so
316 /// two deliveries only collide when the header is absent and they land in
317 /// the same millisecond.)
318 ///
319 /// What: encode โ write temp with `create_new` โ `sync_all` the file โ
320 /// `hard_link` into place โ unlink the temp โ `sync_all` the directory.
321 /// `hard_link` rather than `rename` because rename silently replaces the
322 /// destination while link fails with `EEXIST` โ the atomic refusal this
323 /// needs. The file fsync makes the bytes durable; the directory fsync makes
324 /// the *name* durable, without which a crash can leave the entry
325 /// unreachable even though its data reached the platter.
326 ///
327 /// # Errors
328 ///
329 /// Any [`SpoolError`]. Every one means the delivery is not recorded and the
330 /// caller must return 5xx without acknowledging.
331 ///
332 /// Test: `spool_persists_and_reloads_an_entry_byte_exact`,
333 /// `spool_persist_fails_when_the_root_is_not_a_directory`,
334 /// `spool_persist_new_refuses_to_clobber_an_existing_entry`.
335 pub fn persist_new(&self, entry: &SpoolEntry) -> Result<PathBuf, SpoolError> {
336 let final_path = self.entry_path(entry);
337 let tmp_path = self.write_temp(entry, &final_path)?;
338
339 std::fs::hard_link(&tmp_path, &final_path).map_err(|source| {
340 let _ = std::fs::remove_file(&tmp_path);
341 if source.kind() == std::io::ErrorKind::AlreadyExists {
342 SpoolError::AlreadyExists {
343 path: final_path.clone(),
344 }
345 } else {
346 SpoolError::Commit {
347 from: tmp_path.clone(),
348 to: final_path.clone(),
349 source,
350 }
351 }
352 })?;
353 // The temp name is redundant once the entry is linked under its real
354 // one; the inode survives until the last link goes.
355 let _ = std::fs::remove_file(&tmp_path);
356
357 sync_dir(&self.root)?;
358 Ok(final_path)
359 }
360
361 /// Rewrite an entry that already exists, replacing it atomically.
362 ///
363 /// Why: [`Spool::record_attempt`] needs clobber semantics โ updating the
364 /// attempt count IS overwriting the previous version of the same delivery.
365 /// Split from [`Spool::persist_new`] so the two intents cannot be confused
366 /// at a call site.
367 /// What: identical to `persist_new` except it commits with `rename`, which
368 /// replaces the destination.
369 /// Test: `spool_record_attempt_increments_durably`,
370 /// `spool_persist_update_fails_when_the_final_path_is_a_directory`.
371 pub fn persist_update(&self, entry: &SpoolEntry) -> Result<PathBuf, SpoolError> {
372 let final_path = self.entry_path(entry);
373 let tmp_path = self.write_temp(entry, &final_path)?;
374
375 std::fs::rename(&tmp_path, &final_path).map_err(|source| {
376 // Leaving the temp file behind on a failed rename would accumulate
377 // junk the health scan then has to ignore; drop it here, and let
378 // the rename error stand as the reported failure.
379 let _ = std::fs::remove_file(&tmp_path);
380 SpoolError::Commit {
381 from: tmp_path.clone(),
382 to: final_path.clone(),
383 source,
384 }
385 })?;
386
387 sync_dir(&self.root)?;
388 Ok(final_path)
389 }
390
391 /// Encode `entry` into a fresh, fsync'd temp file beside `final_path`.
392 ///
393 /// The temp name carries the process id and a nanosecond stamp so two
394 /// in-process writers targeting the same entry cannot share one, and uses
395 /// `create_new` so a leftover from a crashed run is never appended to.
396 fn write_temp(&self, entry: &SpoolEntry, final_path: &Path) -> Result<PathBuf, SpoolError> {
397 let bytes = serde_json::to_vec_pretty(entry).map_err(|source| SpoolError::Encode {
398 delivery_id: entry.delivery_id.clone(),
399 source,
400 })?;
401
402 let stamp = std::time::SystemTime::now()
403 .duration_since(std::time::UNIX_EPOCH)
404 .map(|d| d.as_nanos())
405 .unwrap_or(0);
406 let tmp_path =
407 final_path.with_extension(format!("json.{}.{stamp}.tmp", std::process::id()));
408
409 let write = || -> std::io::Result<()> {
410 let mut file = std::fs::OpenOptions::new()
411 .write(true)
412 .create_new(true)
413 .open(&tmp_path)?;
414 file.set_permissions(Permissions::from_mode(SPOOL_FILE_MODE))?;
415 file.write_all(&bytes)?;
416 file.sync_all()
417 };
418 write().map_err(|source| SpoolError::Write {
419 path: tmp_path.clone(),
420 source,
421 })?;
422 Ok(tmp_path)
423 }
424
425 /// Record one failed relay attempt against a spooled entry.
426 ///
427 /// Why: ADR-0034 ยง2 โ "Relay failure โฆ leaves the spool entry `pending`
428 /// with an incremented attempt count. It is never deleted on failure."
429 /// The durable count is what a stuck delivery is diagnosed from; a
430 /// `tracing::warn!` is explicitly forbidden as the sole record.
431 /// What: bumps `attempts`, stores `reason` and the attempt time, and
432 /// rewrites the entry through [`Spool::persist_update`]. Mutates `entry` in
433 /// place so the caller sees the new count.
434 ///
435 /// The rewrite copies the whole entry, body included. That cost is bounded
436 /// by [`super::BackoffPolicy`], which spaces retries exponentially and stops
437 /// them entirely at `max_attempts` โ without it a permanently unrelayable
438 /// delivery would rewrite its own body plus two `fsync`s every sweep tick,
439 /// forever.
440 ///
441 /// Test: `spool_record_attempt_increments_durably`,
442 /// `relay_failure_leaves_a_pending_entry_with_an_incremented_attempt_count`.
443 pub fn record_attempt(
444 &self,
445 entry: &mut SpoolEntry,
446 reason: String,
447 now_unix_ms: u64,
448 ) -> Result<PathBuf, SpoolError> {
449 entry.attempts = entry.attempts.saturating_add(1);
450 entry.last_error = Some(reason);
451 entry.last_attempt_at_unix_ms = Some(now_unix_ms);
452 self.persist_update(entry)
453 }
454
455 /// Delete an entry the target has explicitly acknowledged.
456 ///
457 /// Why: the only deletion path in this module, reachable only from a
458 /// `RelayOutcome::Acked`. "The connection succeeded" is deliberately not
459 /// enough โ ADR-0034 ยง2 makes the explicit ack the sole delete trigger, and
460 /// treating a successful connect as a successful delivery is the same
461 /// silent loss one layer down.
462 /// What: `remove_file`, then fsyncs the directory so the deletion is as
463 /// durable as the creation was. A missing file is not an error โ a
464 /// concurrent sweep may already have removed it.
465 /// Test: `spool_remove_acked_deletes_the_entry`,
466 /// `spool_remove_acked_tolerates_an_already_removed_entry`.
467 pub fn remove_acked(&self, path: &Path) -> Result<(), SpoolError> {
468 match std::fs::remove_file(path) {
469 Ok(()) => {}
470 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
471 Err(source) => {
472 return Err(SpoolError::Remove {
473 path: path.to_path_buf(),
474 source,
475 });
476 }
477 }
478 sync_dir(&self.root)
479 }
480
481 /// Directory holding entries console has given up relaying.
482 pub fn exhausted_root(&self) -> PathBuf {
483 self.root.join(EXHAUSTED_DIR_NAME)
484 }
485
486 /// Move an entry console will not retry into `exhausted/`.
487 ///
488 /// Why: an exhausted entry left in the live directory is read and decoded
489 /// by every sweep and every metrics request, forever โ the spool becomes
490 /// unboundedly expensive to scan precisely because nothing can be relayed.
491 /// It also pins the oldest-pending diagnostics to itself, so a genuinely
492 /// new stuck delivery moves no field an operator or alert rule watches.
493 /// Moving it aside keeps the delivery (it is still an unacknowledged
494 /// webhook) while taking it off both hot paths.
495 /// What: `rename` into [`EXHAUSTED_DIR_NAME`], then `fsync` both
496 /// directories so the move survives a crash. Returns the new path.
497 /// Test: `spool_quarantine_moves_an_entry_out_of_the_live_set`,
498 /// `sweep_quarantines_an_exhausted_entry_and_stops_paying_for_it`.
499 pub fn quarantine(&self, path: &Path) -> Result<PathBuf, SpoolError> {
500 let dest_dir = self.exhausted_root();
501 std::fs::create_dir_all(&dest_dir).map_err(|source| SpoolError::PrepareDir {
502 path: dest_dir.clone(),
503 source,
504 })?;
505 std::fs::set_permissions(&dest_dir, Permissions::from_mode(SPOOL_DIR_MODE)).map_err(
506 |source| SpoolError::PrepareDir {
507 path: dest_dir.clone(),
508 source,
509 },
510 )?;
511
512 let name = path.file_name().ok_or_else(|| SpoolError::Remove {
513 path: path.to_path_buf(),
514 source: std::io::Error::other("spool entry path has no file name"),
515 })?;
516 let dest = dest_dir.join(name);
517 std::fs::rename(path, &dest).map_err(|source| SpoolError::Commit {
518 from: path.to_path_buf(),
519 to: dest.clone(),
520 source,
521 })?;
522 sync_dir(&dest_dir)?;
523 sync_dir(&self.root)?;
524 Ok(dest)
525 }
526
527 /// Decode one entry by path.
528 ///
529 /// Why: the health scan needs `attempts` and `last_error` for exactly one
530 /// entry โ the oldest live one. Decoding just that one keeps the metrics
531 /// request O(1) in decodes rather than O(spool).
532 /// Test: `spool_scan_metadata_avoids_decoding_and_load_reads_one`.
533 pub fn load(&self, path: &Path) -> Result<SpoolEntry, SpoolError> {
534 let bytes = std::fs::read(path).map_err(|source| SpoolError::ReadDir {
535 path: path.to_path_buf(),
536 source,
537 })?;
538 serde_json::from_slice(&bytes).map_err(|source| SpoolError::Encode {
539 delivery_id: path.display().to_string(),
540 source,
541 })
542 }
543
544 /// Filename-only census of both the live and exhausted sets.
545 ///
546 /// Why: [`Spool::entry_path`] encodes the receipt time in the filename
547 /// precisely so age can be read without opening anything, and until now
548 /// nothing used that โ both hot paths decoded every file. A metrics request
549 /// needs counts and ages, which the names already carry.
550 /// What: `read_dir` on the live directory and on `exhausted/`, parsing
551 /// `<received_at_unix_ms:013>-<delivery id>.json`. No file is opened. A name
552 /// that does not parse is reported through `unparsable` rather than
553 /// dropped, for the same reason an undecodable entry is.
554 ///
555 /// A missing live directory is an error for an opened spool, exactly as in
556 /// [`Spool::list_pending`]; a missing `exhausted/` is simply empty, since it
557 /// is created lazily on the first quarantine.
558 ///
559 /// Test: `spool_scan_metadata_avoids_decoding_and_load_reads_one`,
560 /// `spool_scan_metadata_separates_live_from_exhausted`.
561 pub fn scan_metadata(&self) -> Result<SpoolMetadata, SpoolError> {
562 let mut meta = SpoolMetadata::default();
563 collect_metadata(
564 &self.root,
565 self.opened,
566 &mut meta.live,
567 &mut meta.unparsable,
568 )?;
569 collect_metadata(
570 &self.exhausted_root(),
571 false,
572 &mut meta.exhausted,
573 &mut meta.unparsable,
574 )?;
575 meta.live.sort();
576 meta.exhausted.sort();
577 meta.unparsable.sort();
578 Ok(meta)
579 }
580
581 /// Every entry currently pending, oldest first.
582 ///
583 /// Why: both the retry sweep and the health scan need this, and both need
584 /// a *failure* to be distinguishable from an empty spool โ an unreadable
585 /// spool reported as "nothing pending" is the fail-quiet shape again.
586 /// What: reads the live directory, skips temp files, the `exhausted/`
587 /// subdirectory, and anything that is not a `.json` entry, decodes each, and
588 /// sorts by receipt time. An entry that fails to decode is reported through
589 /// `undecodable` rather than dropped.
590 ///
591 /// This decodes every live entry, which is why exhausted ones are moved out
592 /// of it โ the live set is then bounded by the arrival rate over the retry
593 /// window rather than growing without limit. A caller that only needs counts
594 /// and ages should use [`Spool::scan_metadata`], which opens nothing.
595 ///
596 /// Test: `spool_list_pending_orders_oldest_first`,
597 /// `spool_list_pending_reports_an_undecodable_entry`,
598 /// `spool_list_pending_ignores_the_exhausted_subdirectory`.
599 pub fn list_pending(&self) -> Result<PendingListing, SpoolError> {
600 let read = match std::fs::read_dir(&self.root) {
601 Ok(read) => read,
602 // ๐ด An absent directory is only "legitimately empty" for a spool
603 // that was never opened. For an opened one it means the directory
604 // was removed or its volume unmounted, which breaks ingress
605 // completely โ reporting that as an empty listing is what makes
606 // `scan_health` answer green while every delivery 500s. That is the
607 // fail-quiet shape one level below the one this module exists to
608 // remove (ADR-0034 Consequences, "A spool that silently stops being
609 // written reintroduces exactly the failure it was built to
610 // prevent").
611 Err(e) if e.kind() == std::io::ErrorKind::NotFound && !self.opened => {
612 return Ok(PendingListing::default());
613 }
614 Err(source) => {
615 return Err(SpoolError::ReadDir {
616 path: self.root.clone(),
617 source,
618 });
619 }
620 };
621
622 let mut listing = PendingListing::default();
623 for dirent in read {
624 let dirent = dirent.map_err(|source| SpoolError::ReadDir {
625 path: self.root.clone(),
626 source,
627 })?;
628 let path = dirent.path();
629 if path.extension().and_then(|e| e.to_str()) != Some("json") {
630 continue;
631 }
632 match std::fs::read(&path)
633 .map_err(SpoolReadFailure::Io)
634 .and_then(|b| {
635 serde_json::from_slice::<SpoolEntry>(&b).map_err(SpoolReadFailure::Decode)
636 }) {
637 Ok(entry) => listing.pending.push(PendingEntry { path, entry }),
638 Err(failure) => listing.undecodable.push((path, failure.to_string())),
639 }
640 }
641 listing
642 .pending
643 .sort_by_key(|p| (p.entry.received_at_unix_ms, p.path.clone()));
644 listing.undecodable.sort();
645 Ok(listing)
646 }
647}
648
649/// Result of one [`Spool::list_pending`] sweep.
650///
651/// `undecodable` is carried rather than discarded: a file that cannot be parsed
652/// is still an unrelayed delivery, and reporting the spool as empty because its
653/// contents are corrupt is the failure this design exists to prevent.
654#[derive(Debug, Default, Clone)]
655pub struct PendingListing {
656 /// Decodable pending entries, oldest first.
657 pub pending: Vec<PendingEntry>,
658 /// Paths that could not be read or decoded, with the reason.
659 pub undecodable: Vec<(PathBuf, String)>,
660}
661
662/// Why one entry could not be loaded. Internal; flattened to a string for
663/// [`PendingListing::undecodable`].
664#[derive(Debug, thiserror::Error)]
665enum SpoolReadFailure {
666 #[error("read: {0}")]
667 Io(#[from] std::io::Error),
668 #[error("decode: {0}")]
669 Decode(#[from] serde_json::Error),
670}
671
672/// One entry as described by its filename alone โ no file was opened.
673#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
674pub struct EntryMeta {
675 /// When console accepted the delivery. Sorts first so a plain sort is an
676 /// age sort.
677 pub received_at_unix_ms: u64,
678 /// The sanitised delivery id from the filename.
679 pub delivery_id: String,
680 /// Absolute path of the entry file.
681 pub path: PathBuf,
682}
683
684/// Result of one [`Spool::scan_metadata`] pass.
685#[derive(Debug, Default, Clone)]
686pub struct SpoolMetadata {
687 /// Live entries still eligible for relay, oldest first.
688 pub live: Vec<EntryMeta>,
689 /// Entries console has given up relaying, oldest first.
690 pub exhausted: Vec<EntryMeta>,
691 /// `.json` files whose name does not parse, with the reason.
692 pub unparsable: Vec<(PathBuf, String)>,
693}
694
695/// Fill `out` from `dir` using filenames only.
696///
697/// `required` mirrors [`Spool`]'s `opened` flag: when true a missing directory
698/// is an error, when false it is an empty set.
699fn collect_metadata(
700 dir: &Path,
701 required: bool,
702 out: &mut Vec<EntryMeta>,
703 unparsable: &mut Vec<(PathBuf, String)>,
704) -> Result<(), SpoolError> {
705 let read = match std::fs::read_dir(dir) {
706 Ok(read) => read,
707 Err(e) if e.kind() == std::io::ErrorKind::NotFound && !required => return Ok(()),
708 Err(source) => {
709 return Err(SpoolError::ReadDir {
710 path: dir.to_path_buf(),
711 source,
712 });
713 }
714 };
715 for dirent in read {
716 let dirent = dirent.map_err(|source| SpoolError::ReadDir {
717 path: dir.to_path_buf(),
718 source,
719 })?;
720 let path = dirent.path();
721 if path.extension().and_then(|e| e.to_str()) != Some("json") {
722 continue;
723 }
724 match path
725 .file_name()
726 .and_then(|n| n.to_str())
727 .and_then(parse_entry_filename)
728 {
729 Some((received_at_unix_ms, delivery_id)) => out.push(EntryMeta {
730 received_at_unix_ms,
731 delivery_id,
732 path,
733 }),
734 None => unparsable.push((path, "filename does not carry a receipt timestamp".into())),
735 }
736 }
737 Ok(())
738}
739
740/// Split `<received_at_unix_ms:013>-<delivery id>.json` back into its parts.
741///
742/// The inverse of [`Spool::entry_path`]'s format. Returns `None` for any name
743/// that does not match, so a stray file is reported rather than silently
744/// treated as age zero (which would read as the oldest entry in the spool).
745fn parse_entry_filename(name: &str) -> Option<(u64, String)> {
746 let stem = name.strip_suffix(".json")?;
747 let (ts, id) = stem.split_once('-')?;
748 let received = ts.parse::<u64>().ok()?;
749 Some((received, id.to_string()))
750}
751
752/// `fsync` a directory so a rename or unlink within it is durable.
753fn sync_dir(dir: &Path) -> Result<(), SpoolError> {
754 File::open(dir)
755 .and_then(|d| d.sync_all())
756 .map_err(|source| SpoolError::SyncDir {
757 path: dir.to_path_buf(),
758 source,
759 })
760}
761
762/// Reduce a delivery id to something safe to use as a filename component.
763///
764/// A `X-GitHub-Delivery` header is attacker-controlled as far as this process
765/// is concerned โ the HMAC covers the body, not the headers โ so `../` or a
766/// 4 KiB value must not reach `Path::join`.
767fn sanitise_delivery_id(raw: &str) -> String {
768 let cleaned: String = raw
769 .chars()
770 .map(|c| {
771 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
772 c
773 } else {
774 '_'
775 }
776 })
777 .take(64)
778 .collect();
779 if cleaned.is_empty() {
780 "unknown".to_string()
781 } else {
782 cleaned
783 }
784}