Skip to content

Commit 0ed10a5

Browse files
committed
fix(segment): authenticate persisted metadata during reconciliation
Validate catalog routing, segment headers and footers, geometry, and extent indexes before recovery promotes or removes files. Preserve per-segment key and cipher routing while sharing authenticated metadata checks across readers and recovery.
1 parent 599abb2 commit 0ed10a5

20 files changed

Lines changed: 1804 additions & 963 deletions

File tree

src/catalog/codec.rs

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,27 @@ impl Catalog {
201201
Ok(k)
202202
}
203203

204+
/// Validate and return the name suffix of a segment-row key. This is used
205+
/// before recovery derives a diagnostic name from authenticated catalog
206+
/// bytes, so malformed rows cannot cause a slice panic or a repair action.
207+
pub fn validate_segment_key<'a>(key: &'a [u8], meta: &SegmentMeta) -> Result<&'a [u8]> {
208+
const SEGMENT_KEY_PREFIX_LEN: usize = 1 + 16;
209+
if key.first().copied() != Some(CatalogRowKind::Segment as u8) {
210+
return Err(PagedbError::catalog_row_invalid("segment.key.kind"));
211+
}
212+
if key.len() < SEGMENT_KEY_PREFIX_LEN {
213+
return Err(PagedbError::catalog_row_invalid("segment.key.length"));
214+
}
215+
let name = &key[SEGMENT_KEY_PREFIX_LEN..];
216+
if name.len() > MAX_SEGMENT_NAME_LEN {
217+
return Err(PagedbError::catalog_row_invalid("segment.key.name_length"));
218+
}
219+
if key[1..SEGMENT_KEY_PREFIX_LEN] != meta.realm_id.0[..] {
220+
return Err(PagedbError::catalog_row_invalid("segment.key.realm_id"));
221+
}
222+
Ok(name)
223+
}
224+
204225
/// Rekey-state row key: `[0x03]` (singleton, no suffix).
205226
#[must_use]
206227
pub fn rekey_state_key() -> Vec<u8> {
@@ -489,12 +510,25 @@ impl Catalog {
489510
b.copy_from_slice(&bytes[33..49]);
490511
b
491512
};
492-
let linked_commit = if bytes[49] == 1 {
493-
let mut b = [0u8; 8];
494-
b.copy_from_slice(&bytes[50..58]);
495-
Some(CommitId(u64::from_le_bytes(b)))
496-
} else {
497-
None
513+
let linked_commit = match bytes[49] {
514+
0 => {
515+
if bytes[50..58].iter().any(|byte| *byte != 0) {
516+
return Err(PagedbError::catalog_row_invalid(
517+
"segment_meta.linked_commit",
518+
));
519+
}
520+
None
521+
}
522+
1 => {
523+
let mut b = [0u8; 8];
524+
b.copy_from_slice(&bytes[50..58]);
525+
Some(CommitId(u64::from_le_bytes(b)))
526+
}
527+
_ => {
528+
return Err(PagedbError::catalog_row_invalid(
529+
"segment_meta.linked_commit",
530+
));
531+
}
498532
};
499533
let mut buf = [0u8; 8];
500534
buf.copy_from_slice(&bytes[58..66]);
@@ -718,6 +752,74 @@ mod tests {
718752
assert!(matches!(err, PagedbError::Corruption { .. }));
719753
}
720754

755+
#[test]
756+
fn segment_meta_rejects_invalid_linked_commit_discriminator_and_unused_bytes() {
757+
let meta = SegmentMeta {
758+
segment_id: [9; 16],
759+
segment_kind: SegmentKind::Unspecified,
760+
realm_id: RealmId([0; 16]),
761+
parent_file_id: [0; 16],
762+
linked_commit: None,
763+
page_count: 2,
764+
total_bytes: 8192,
765+
final_counter: 0,
766+
mk_epoch: 0,
767+
cipher_id: 1,
768+
format_version: 1,
769+
evictable: Evictable::Authoritative,
770+
};
771+
let mut encoded = Catalog::encode_segment_meta(&meta);
772+
encoded[49] = 2;
773+
assert!(matches!(
774+
Catalog::decode_segment_meta(&encoded),
775+
Err(PagedbError::Corruption(
776+
crate::errors::CorruptionDetail::CatalogRowInvalid {
777+
field: "segment_meta.linked_commit"
778+
}
779+
))
780+
));
781+
782+
let mut encoded = Catalog::encode_segment_meta(&meta);
783+
encoded[50] = 1;
784+
assert!(matches!(
785+
Catalog::decode_segment_meta(&encoded),
786+
Err(PagedbError::Corruption(
787+
crate::errors::CorruptionDetail::CatalogRowInvalid {
788+
field: "segment_meta.linked_commit"
789+
}
790+
))
791+
));
792+
}
793+
794+
#[test]
795+
fn segment_key_validation_rejects_malformed_routing_bytes() {
796+
let meta = SegmentMeta {
797+
segment_id: [1; 16],
798+
segment_kind: SegmentKind::Unspecified,
799+
realm_id: RealmId([2; 16]),
800+
parent_file_id: [3; 16],
801+
linked_commit: None,
802+
page_count: 2,
803+
total_bytes: 8192,
804+
final_counter: 0,
805+
mk_epoch: 0,
806+
cipher_id: 1,
807+
format_version: 1,
808+
evictable: Evictable::Authoritative,
809+
};
810+
assert!(Catalog::validate_segment_key(&[], &meta).is_err());
811+
assert!(Catalog::validate_segment_key(&[CatalogRowKind::Quota as u8; 17], &meta).is_err());
812+
assert!(
813+
Catalog::validate_segment_key(&[CatalogRowKind::Segment as u8; 16], &meta).is_err()
814+
);
815+
let wrong_realm = Catalog::segment_key(RealmId([4; 16]), b"name").unwrap();
816+
assert!(Catalog::validate_segment_key(&wrong_realm, &meta).is_err());
817+
let mut long_name = vec![CatalogRowKind::Segment as u8];
818+
long_name.extend_from_slice(&meta.realm_id.0);
819+
long_name.extend_from_slice(&vec![b'n'; MAX_SEGMENT_NAME_LEN + 1]);
820+
assert!(Catalog::validate_segment_key(&long_name, &meta).is_err());
821+
}
822+
721823
#[test]
722824
fn segment_meta_unlinked_round_trip() {
723825
let m = SegmentMeta {

src/errors.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ pub enum PagedbError {
99
#[error("checksum / AEAD tag verification failed")]
1010
ChecksumFailure,
1111

12+
#[error("required persisted key is unavailable: mk_epoch={mk_epoch} cipher_id={cipher_id}")]
13+
MissingPersistedKey { mk_epoch: u64, cipher_id: u8 },
14+
1215
#[error("corruption: {0:?}")]
1316
Corruption(CorruptionDetail),
1417

@@ -87,6 +90,9 @@ pub enum PagedbError {
8790
#[error("payload too large")]
8891
PayloadTooLarge,
8992

93+
#[error("extent must contain at least one page")]
94+
EmptyExtent,
95+
9096
#[error("manifest too large")]
9197
ManifestTooLarge,
9298

@@ -183,6 +189,12 @@ pub enum CorruptionDetail {
183189
name: String,
184190
segment_id: [u8; 16],
185191
},
192+
/// Authenticated segment metadata differs from its trusted catalog routing entry.
193+
SegmentMetadataMismatch { field: &'static str },
194+
/// Segment file geometry cannot safely locate its authenticated footer.
195+
SegmentGeometryInvalid { field: &'static str },
196+
/// Authenticated catalog row bytes do not form a valid segment key/value pair.
197+
CatalogRowInvalid { field: &'static str },
186198
/// Catalog references a segment whose file is absent from both `seg/` and `seg/.staging/`.
187199
SegmentMissing {
188200
realm_id: RealmId,
@@ -237,6 +249,24 @@ impl PagedbError {
237249
Self::Corruption(detail)
238250
}
239251

252+
/// Canonical constructor for authenticated catalog/file metadata disagreement.
253+
#[must_use]
254+
pub const fn segment_metadata_mismatch(field: &'static str) -> Self {
255+
Self::Corruption(CorruptionDetail::SegmentMetadataMismatch { field })
256+
}
257+
258+
/// Canonical constructor for malformed segment-file geometry.
259+
#[must_use]
260+
pub const fn segment_geometry_invalid(field: &'static str) -> Self {
261+
Self::Corruption(CorruptionDetail::SegmentGeometryInvalid { field })
262+
}
263+
264+
/// Canonical constructor for malformed authenticated catalog rows.
265+
#[must_use]
266+
pub const fn catalog_row_invalid(field: &'static str) -> Self {
267+
Self::Corruption(CorruptionDetail::CatalogRowInvalid { field })
268+
}
269+
240270
/// Canonical constructor for an incremental snapshot that cannot be
241271
/// applied to this handle's current identity or reader-visible state.
242272
#[must_use]

0 commit comments

Comments
 (0)