Skip to main content

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}