1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Black-box diagnostics wiring.
//!
//! Thin, always-callable shims over the `faultbox` recorder: breadcrumbs on the
//! significant operations (commit, reopen) and a structured corruption report
//! at the page read-verify failure site — the exact place a freed-page
//! use-after-free surfaces as an AEAD/MAC failure.
//!
//! The real implementation is compiled only under the `diagnostics` feature and
//! off wasm32; otherwise every entry point is a no-op with the same signature,
//! so call sites never need `cfg`. Reports are inert until the host application
//! calls [`faultbox::init`], so a library emitting these costs nothing on its
//! own.
#[cfg(all(feature = "diagnostics", not(target_arch = "wasm32")))]
mod imp {
use std::cell::Cell;
use std::marker::PhantomData;
use crate::RealmId;
use crate::errors::CorruptionDetail;
thread_local! {
/// Live [`RichlyReported`] guards on this thread. A depth rather than a
/// flag: nesting must not leave the suppression stuck on when the inner
/// guard drops.
static RICH_REPORTS_IN_FLIGHT: Cell<u32> = const { Cell::new(0) };
}
/// Evidence that a site-specific report already covers the corruption about
/// to be constructed.
///
/// Every rich capture in this module returns one. Bind it across the
/// `PagedbError::corruption(...)` call that reports the same failure and
/// [`corruption_captured`] stands down for that construction, so one
/// failure files one report — the site's, which carries forensics (the
/// failing page, the fsck hint, the store path) that the constructor, which
/// sees only a `CorruptionDetail`, cannot know.
///
/// This is the mechanism, not a special case: a second site that grows its
/// own `DomainContext` gets the same suppression by returning this type
/// from its capture function, with nothing to rediscover.
///
/// Deliberately not `Send`. The guard covers the construction immediately
/// following it and nothing else; holding it across an `.await` would
/// silence unrelated corruption raised by whatever ran in between, and the
/// compiler rejects that wherever the surrounding future must be `Send`.
#[must_use = "bind the guard so it outlives the corruption() call it covers"]
pub struct RichlyReported {
_not_send: PhantomData<*const ()>,
}
impl RichlyReported {
fn begin() -> Self {
RICH_REPORTS_IN_FLIGHT.with(|depth| depth.set(depth.get().saturating_add(1)));
Self {
_not_send: PhantomData,
}
}
}
impl Drop for RichlyReported {
fn drop(&mut self) {
RICH_REPORTS_IN_FLIGHT.with(|depth| depth.set(depth.get().saturating_sub(1)));
}
}
/// Breadcrumb: a store was (re)opened — marks epoch boundaries in the trail,
/// the dimension along which freed-page use-after-free surfaces.
pub fn reopened(latest_commit: u64) {
faultbox::breadcrumb!(Info, "pagedb.reopen", "opened store", { "latest_commit": latest_commit });
}
/// Breadcrumb: a commit was published, with the number of pages it freed —
/// the operations most implicated in page recycling.
pub fn committed(commit_id: u64, freed_pages: usize) {
faultbox::breadcrumb!(Debug, "pagedb.commit", "committed", {
"commit_id": commit_id,
"freed_pages": freed_pages,
});
}
/// Breadcrumb: a sentinel lock (writer, frozen-reader, or observer) was
/// acquired at open — marks who holds exclusivity over a store.
pub fn lock_acquired(mode: &str, path: &str) {
faultbox::breadcrumb!(Info, "pagedb.lock_acquired", "sentinel lock acquired", {
"mode": mode,
"path": path,
});
}
/// Breadcrumb: a sentinel lock acquisition was rejected — a concurrent
/// open lost the race, so the failure has a trail even though it never
/// touched the store.
pub fn lock_rejected(mode: &str, path: &str, reason: &str) {
faultbox::breadcrumb!(Info, "pagedb.lock_rejected", "sentinel lock rejected", {
"mode": mode,
"path": path,
"reason": reason,
});
}
/// Breadcrumb: dirty pages were flushed and fsynced to durable storage.
pub fn flushed(bytes: u64) {
faultbox::breadcrumb!(Debug, "pagedb.flushed", "flushed to disk", {
"bytes": bytes,
});
}
/// Forensic context for a page that failed AEAD/MAC verification on read —
/// mirrors what `pagedb-fsck` would report for the same page.
struct PageReadVerifyFailure {
page_id: u64,
file: String,
binding: String,
realm_hex: String,
main_db_path: String,
}
impl faultbox::DomainContext for PageReadVerifyFailure {
fn domain_kind(&self) -> &'static str {
"pagedb.page_read_verify_failure"
}
fn grouping_key(&self) -> String {
// Group by (file, expected binding), NOT the specific page id, so
// instances of one structural bug collapse together.
format!("file={};binding={}", self.file, self.binding)
}
fn to_json(&self) -> faultbox::serde_json::Value {
faultbox::serde_json::json!({
"page_id": self.page_id,
"file": self.file,
"binding": self.binding,
"realm": self.realm_hex,
// VFS-relative store path. Preserving the store for offline
// `pagedb-fsck` is the host application's job — only it knows
// the real on-disk directory behind the VFS.
"main_db_path": self.main_db_path,
"fsck_hint": format!("pagedb-fsck <store-dir> --deep --realm {}", self.realm_hex),
})
}
}
/// Capture a structured corruption report for a page that would not
/// authenticate. Records the failing page, expected binding, and realm so
/// the failure is diagnosable from the report alone; the host application
/// preserves the store bytes (it owns the real path behind the VFS).
///
/// Returns a [`RichlyReported`] guard: bind it across the
/// `PagedbError::corruption(...)` that turns this same failure into an
/// error, so the caller gets the precise variant while this report — not
/// the constructor's generic one — is what gets filed.
pub fn page_read_verify_failed(
main_db_path: &str,
page_id: u64,
file: &str,
binding: &str,
realm: &RealmId,
) -> RichlyReported {
let ctx = PageReadVerifyFailure {
page_id,
file: file.to_owned(),
binding: binding.to_owned(),
realm_hex: crate::hex::to_hex_lower(&realm.0),
main_db_path: main_db_path.to_owned(),
};
let _ = faultbox::Capture::new(
faultbox::EventKind::Corruption,
"page AEAD/MAC verification failed on read",
)
.domain(&ctx)
.with_backtrace()
.emit();
RichlyReported::begin()
}
/// Forensic context for a [`CorruptionDetail`] captured at the moment
/// `PagedbError::corruption()` constructs it — the one funnel every
/// `CorruptionDetail` variant passes through.
struct CorruptionConstructed {
/// Stable per-variant identifier, independent of the instance's field
/// values, so `faultbox`'s domain-kind bucket is the failure *mode*.
kind: &'static str,
/// Coalescing key: variant name plus whichever fields identify the
/// structural bug rather than the specific instance (never a raw
/// `page_id`/`segment_id`/counter alone) — the same grouping
/// discipline `PageReadVerifyFailure` already uses, so one bug landed
/// once still collapses to one report under fuzzing.
grouping_key: String,
/// Full `Debug` rendering of the detail, for a report that is
/// diagnosable on its own.
detail_debug: String,
}
impl faultbox::DomainContext for CorruptionConstructed {
fn domain_kind(&self) -> &'static str {
self.kind
}
fn grouping_key(&self) -> String {
self.grouping_key.clone()
}
fn to_json(&self) -> faultbox::serde_json::Value {
faultbox::serde_json::json!({ "detail": self.detail_debug })
}
}
/// Classify a [`CorruptionDetail`] into a stable domain kind and a
/// grouping key that names the structural fields of the failure but never
/// the instance-specific ones (page ids, segment ids), so many
/// occurrences of one bug — the common shape under fuzzing — coalesce
/// into one report instead of one per occurrence.
fn classify(detail: &CorruptionDetail) -> (&'static str, String) {
match detail {
CorruptionDetail::ForeignSegment { .. } => (
"pagedb.corruption.foreign_segment",
"ForeignSegment".to_owned(),
),
CorruptionDetail::FooterUnverifiable { .. } => (
"pagedb.corruption.footer_unverifiable",
"FooterUnverifiable".to_owned(),
),
CorruptionDetail::SegmentMetadataMismatch { field } => (
"pagedb.corruption.segment_metadata_mismatch",
format!("SegmentMetadataMismatch:field={field}"),
),
CorruptionDetail::SegmentGeometryInvalid { field } => (
"pagedb.corruption.segment_geometry_invalid",
format!("SegmentGeometryInvalid:field={field}"),
),
CorruptionDetail::CatalogRowInvalid { field } => (
"pagedb.corruption.catalog_row_invalid",
format!("CatalogRowInvalid:field={field}"),
),
CorruptionDetail::SegmentMissing { .. } => (
"pagedb.corruption.segment_missing",
"SegmentMissing".to_owned(),
),
CorruptionDetail::StagingMissing { .. } => (
"pagedb.corruption.staging_missing",
"StagingMissing".to_owned(),
),
CorruptionDetail::PageUnverifiable { .. } => (
"pagedb.corruption.page_unverifiable",
"PageUnverifiable".to_owned(),
),
CorruptionDetail::ManifestUnverifiable { .. } => (
"pagedb.corruption.manifest_unverifiable",
"ManifestUnverifiable".to_owned(),
),
CorruptionDetail::HeaderUnverifiable => (
"pagedb.corruption.header_unverifiable",
"HeaderUnverifiable".to_owned(),
),
CorruptionDetail::StructuralHeaderInvalid { header, field } => (
"pagedb.corruption.structural_header_invalid",
format!("StructuralHeaderInvalid:header={header}:field={field}"),
),
CorruptionDetail::FooterFramingInvalid { field } => (
"pagedb.corruption.footer_framing_invalid",
format!("FooterFramingInvalid:field={field}"),
),
CorruptionDetail::NodeBodyMalformed { field } => (
"pagedb.corruption.node_body_malformed",
format!("NodeBodyMalformed:field={field}"),
),
CorruptionDetail::NodeKindMismatch {
expected, found, ..
} => (
"pagedb.corruption.node_kind_mismatch",
format!("NodeKindMismatch:expected={expected}:found={found}"),
),
CorruptionDetail::OverflowBodyMalformed { field } => (
"pagedb.corruption.overflow_body_malformed",
format!("OverflowBodyMalformed:field={field}"),
),
CorruptionDetail::JournalRecordMalformed { field } => (
"pagedb.corruption.journal_record_malformed",
format!("JournalRecordMalformed:field={field}"),
),
CorruptionDetail::SnapshotArtifactInvalid { field } => (
"pagedb.corruption.snapshot_artifact_invalid",
format!("SnapshotArtifactInvalid:field={field}"),
),
CorruptionDetail::ReservedPageReferenced { .. } => (
"pagedb.corruption.reserved_page_referenced",
"ReservedPageReferenced".to_owned(),
),
CorruptionDetail::OverflowChainCycle { .. } => (
"pagedb.corruption.overflow_chain_cycle",
"OverflowChainCycle".to_owned(),
),
CorruptionDetail::PageChainCycle { structure, .. } => (
"pagedb.corruption.page_chain_cycle",
format!("PageChainCycle:structure={structure}"),
),
CorruptionDetail::LeafSiblingMismatch { .. } => (
"pagedb.corruption.leaf_sibling_mismatch",
"LeafSiblingMismatch".to_owned(),
),
CorruptionDetail::PageKindAliased {
walked_as,
referenced_as,
..
} => (
"pagedb.corruption.page_kind_aliased",
format!("PageKindAliased:walked_as={walked_as}:referenced_as={referenced_as}"),
),
// `CorruptionDetail` is `#[non_exhaustive]`: within this crate that
// only guards against missing a match arm when a variant is added,
// not against external construction, so a catch-all still reports
// *something* rather than silently dropping a future variant.
#[allow(unreachable_patterns)]
_ => ("pagedb.corruption.other", "Other".to_owned()),
}
}
/// Capture a structured corruption report at the moment a
/// [`CorruptionDetail`] is constructed — called from
/// `PagedbError::corruption()`, the single funnel every variant passes
/// through, so every precise diagnosis in the taxonomy reaches the
/// reporting layer instead of only the one AEAD failure site this module
/// originally covered.
pub fn corruption_captured(detail: &CorruptionDetail) {
// A site-specific capture is already in flight for this exact failure
// and carries strictly more than this one could. Filing both would mean
// two reports for one event, the second of them less useful.
if RICH_REPORTS_IN_FLIGHT.with(Cell::get) > 0 {
return;
}
let (kind, grouping_key) = classify(detail);
let ctx = CorruptionConstructed {
kind,
grouping_key,
detail_debug: format!("{detail:?}"),
};
let _ = faultbox::Capture::new(
faultbox::EventKind::Corruption,
"corruption detail constructed",
)
.domain(&ctx)
.with_backtrace()
.emit();
}
}
#[cfg(not(all(feature = "diagnostics", not(target_arch = "wasm32"))))]
mod imp {
use crate::RealmId;
/// No-op counterpart of the recording build's suppression guard, so call
/// sites bind the same value under every cfg.
pub struct RichlyReported;
pub fn reopened(_latest_commit: u64) {}
pub fn committed(_commit_id: u64, _freed_pages: usize) {}
pub fn lock_acquired(_mode: &str, _path: &str) {}
pub fn lock_rejected(_mode: &str, _path: &str, _reason: &str) {}
pub fn flushed(_bytes: u64) {}
pub fn page_read_verify_failed(
_main_db_path: &str,
_page_id: u64,
_file: &str,
_binding: &str,
_realm: &RealmId,
) -> RichlyReported {
RichlyReported
}
pub fn corruption_captured(_detail: &crate::errors::CorruptionDetail) {}
}
// `RichlyReported` is deliberately not re-exported: a call site binds it as the
// return value of a rich capture (`let _report = diag::page_read_verify_failed(…)`)
// and never names the type, so exporting it would only be an unused path.
pub use imp::{
committed, corruption_captured, flushed, lock_acquired, lock_rejected, page_read_verify_failed,
reopened,
};
/// Convenience for call sites that hold `Debug`-only types: format a value for
/// a report field. Kept here so the (rare, failure-path-only) allocation is
/// obviously intentional.
#[must_use]
pub fn dbg_str<T: std::fmt::Debug>(value: &T) -> String {
format!("{value:?}")
}