From adb59f78c3f5cc6e845de53e612c80a13f979b6e Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 1 Aug 2026 18:11:36 +0700 Subject: [PATCH 1/2] fix: validated rkyv access on every disk-read path A process that dies mid-write (crash, SIGKILL, an exit without draining) leaves torn pages, and access_unchecked read them back as archived values whose relative pointers dangle anywhere in the address space. The reader then died of SIGBUS in whatever touched them, usually mid-write, tearing the store further: on 2026-08-01 that cascade corrupted one production store four times in a day, and the diagnostic was a validation error carrying a 7.6GB subtree pointer. Every access_unchecked on the read path, including the six inside the Persistable derive templates, is now rkyv::access with bytecheck: a torn page surfaces as an error naming corruption (fallible paths) or a named panic (the infallible Persistable::from_bytes contract), while the store on disk stays exactly as readable as it was. Serialization paths and same-process roundtrip tests keep unchecked access; the bytes never crossed a process boundary there. The cost is CheckBytes bounds on the generic read paths, satisfied automatically by every derived Archive type, and validation work at load time only. --- Cargo.toml | 4 +- codegen/Cargo.toml | 2 +- .../persistable/generator/persistable_impl.rs | 14 ++-- src/page/index/page.rs | 50 +++++++++--- src/page/index/page_for_unsized.rs | 76 +++++++++++++------ src/page/index/page_for_unsized_cdc_impl.rs | 3 +- src/page/index/table_of_contents_page.rs | 14 +++- src/page/iterators.rs | 11 +-- src/page/space_info.rs | 4 +- src/page/util.rs | 9 ++- src/util/persistable.rs | 36 +++++++-- 11 files changed, 159 insertions(+), 64 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4c77129..4895cda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["codegen", "tools/create-data-file", "tools/dump-data-file"] [package] name = "data_bucket" -version = "0.4.0" +version = "0.4.1" edition = "2021" authors = ["Handy-caT"] license = "MIT" @@ -11,7 +11,7 @@ repository = "https://github.com/pathscale/DataBucket" description = "DataBucket is container for WorkTable's data" [dependencies] -data_bucket_derive = { path = "codegen", version = "=0.3.15" } +data_bucket_derive = { path = "codegen", version = "=0.3.16" } eyre = "0.6.12" derive_more = { version = "1.0.0", features = ["from", "error", "display", "into"] } diff --git a/codegen/Cargo.toml b/codegen/Cargo.toml index 17657c8..65884f9 100644 --- a/codegen/Cargo.toml +++ b/codegen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "data_bucket_derive" -version = "0.3.15" +version = "0.3.16" edition = "2021" authors = ["Handy-caT"] license = "MIT" diff --git a/codegen/src/persistable/generator/persistable_impl.rs b/codegen/src/persistable/generator/persistable_impl.rs index 1337cb6..90137b5 100644 --- a/codegen/src/persistable/generator/persistable_impl.rs +++ b/codegen/src/persistable/generator/persistable_impl.rs @@ -47,7 +47,7 @@ impl Generator { rkyv::ser::Serializer, rkyv::ser::sharing::Share>, rkyv::rancor::Error>, >, - <#ident as rkyv::Archive>::Archived: rkyv::Deserialize<#ident, rkyv::api::high::HighDeserializer> #archived_bounds, + <#ident as rkyv::Archive>::Archived: rkyv::Deserialize<#ident, rkyv::api::high::HighDeserializer> + for<'a> rkyv::bytecheck::CheckBytes> #archived_bounds, } } else { quote! {} @@ -90,7 +90,7 @@ impl Generator { } fn from_bytes(bytes: &[u8], _version: u32) -> Self { - let archived = unsafe { rkyv::access_unchecked::<::Archived>(bytes) }; + let archived = rkyv::access::<::Archived, rkyv::rancor::Error>(bytes).expect("torn or corrupt page: the archived bytes fail validation"); rkyv::deserialize::<_, rkyv::rancor::Error>(archived).expect("data should be valid") } } @@ -214,7 +214,7 @@ impl Generator { quote! { let size_length = <#size_type as Default>::default().aligned_size(); let archived = - unsafe { rkyv::access_unchecked::<<#size_type as Archive>::Archived>(&bytes[offset..offset + size_length]) }; + rkyv::access::<<#size_type as Archive>::Archived, rkyv::rancor::Error>(&bytes[offset..offset + size_length]).expect("torn or corrupt page part: a size field fails validation"); let #size_ident = rkyv::deserialize::<#size_type, rkyv::rancor::Error>(archived).expect("data should be valid"); offset += size_length; @@ -240,7 +240,7 @@ impl Generator { let length = <#ty as Default>::default().aligned_size(); let mut v = rkyv::util::AlignedVec::<4>::new(); v.extend_from_slice(&bytes[offset..offset + length]); - let archived = unsafe { rkyv::access_unchecked::<<#ty as Archive>::Archived>(&v[..]) }; + let archived = rkyv::access::<<#ty as Archive>::Archived, rkyv::rancor::Error>(&v[..]).expect("torn or corrupt page part: a field fails validation"); let #ident = rkyv::deserialize::<_, rkyv::rancor::Error>(archived).expect("data should be valid"); offset += length; } @@ -283,7 +283,7 @@ impl Generator { let mut v = rkyv::util::AlignedVec::<4>::new(); v.extend_from_slice(&bytes[offset..offset + values_len]); let archived = - unsafe { rkyv::access_unchecked::<<#ty as Archive>::Archived>(&v[..]) }; + rkyv::access::<<#ty as Archive>::Archived, rkyv::rancor::Error>(&v[..]).expect("torn or corrupt page part: a field fails validation"); let #ident = rkyv::deserialize::<#ty, rkyv::rancor::Error>(archived) .expect("data should be valid"); offset += values_len; @@ -313,7 +313,7 @@ impl Generator { let mut v = rkyv::util::AlignedVec::<4>::new(); v.extend_from_slice(&bytes[offset..offset + values_len]); let archived = - unsafe { rkyv::access_unchecked::<<#ty as Archive>::Archived>(&v[..]) }; + rkyv::access::<<#ty as Archive>::Archived, rkyv::rancor::Error>(&v[..]).expect("torn or corrupt page part: a field fails validation"); let #ident = rkyv::deserialize::<#ty, rkyv::rancor::Error>(archived) .expect("data should be valid"); offset += values_len; @@ -343,7 +343,7 @@ impl Generator { let mut v = rkyv::util::AlignedVec::<4>::new(); v.extend_from_slice(&bytes[offset..offset + values_len]); let archived = - unsafe { rkyv::access_unchecked::<<#ty as Archive>::Archived>(&v[..]) }; + rkyv::access::<<#ty as Archive>::Archived, rkyv::rancor::Error>(&v[..]).expect("torn or corrupt page part: a field fails validation"); let #ident = rkyv::deserialize::<#ty, rkyv::rancor::Error>(archived) .expect("data should be valid"); offset += values_len; diff --git a/src/page/index/page.rs b/src/page/index/page.rs index 974c6d7..c39ebfb 100644 --- a/src/page/index/page.rs +++ b/src/page/index/page.rs @@ -79,7 +79,8 @@ where Strategy, Share>, rkyv::rancor::Error>, > + Send + Sync, - ::Archived: Deserialize>, + ::Archived: Deserialize> + + for<'a> rkyv::bytecheck::CheckBytes>, { type Utility = SizedIndexPageUtility; @@ -93,11 +94,12 @@ where let mut size_bytes = vec![0u8; SizedIndexPageUtility::::size_size()]; file.read_exact(size_bytes.as_mut_slice()).await?; - let archived = unsafe { - rkyv::access_unchecked::<::Archived>( - &size_bytes[0..SizedIndexPageUtility::::size_size()], - ) - }; + // Validated: this length field steers how much of the page is read + // as index entries, so a torn page must fail here, loudly. + let archived = rkyv::access::<::Archived, rkyv::rancor::Error>( + &size_bytes[0..SizedIndexPageUtility::::size_size()], + ) + .map_err(|error| eyre::eyre!("torn or corrupt index page size field: {error}"))?; let size = rkyv::deserialize::(archived).expect("data should be valid"); @@ -160,14 +162,22 @@ impl IndexPage { async fn read_value(file: &mut File) -> eyre::Result> where T: Archive, - ::Archived: Deserialize>, + ::Archived: Deserialize> + + for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, + as Archive>::Archived: for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, { let mut bytes = vec![0u8; IndexPage::::index_values_value_size()]; file.read_exact(bytes.as_mut_slice()).await?; let mut v = AlignedVec::<4>::new(); v.extend_from_slice(bytes.as_slice()); + // Validated: a torn index entry must be an error, not a dangling link. let archived = - unsafe { rkyv::access_unchecked::< as Archive>::Archived>(&v[..]) }; + rkyv::access::< as Archive>::Archived, rkyv::rancor::Error>(&v[..]) + .map_err(|error| eyre::eyre!("torn or corrupt index entry: {error}"))?; Ok(rkyv::deserialize(archived).expect("data should be valid")) } @@ -179,7 +189,13 @@ impl IndexPage { ) -> eyre::Result> where T: Archive, - ::Archived: Deserialize>, + ::Archived: Deserialize> + + for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, + as Archive>::Archived: for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, { seek_to_page_start(file, page_id.0).await?; let offset = Self::get_value_offset(size, index); @@ -215,7 +231,13 @@ impl IndexPage { + for<'a> Serialize< Strategy, Share>, rkyv::rancor::Error>, >, - ::Archived: Deserialize>, + ::Archived: Deserialize> + + for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, + as Archive>::Archived: for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, { seek_to_page_start(file, page_id.0).await?; @@ -249,7 +271,13 @@ impl IndexPage { + for<'a> Serialize< Strategy, Share>, rkyv::rancor::Error>, >, - ::Archived: Deserialize>, + ::Archived: Deserialize> + + for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, + as Archive>::Archived: for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, { seek_to_page_start(file, page_id.0).await?; diff --git a/src/page/index/page_for_unsized.rs b/src/page/index/page_for_unsized.rs index e7d657d..ba9e2b6 100644 --- a/src/page/index/page_for_unsized.rs +++ b/src/page/index/page_for_unsized.rs @@ -64,7 +64,8 @@ where Strategy, Share>, rkyv::rancor::Error>, > + Send + Sync, - ::Archived: Deserialize>, + ::Archived: Deserialize> + + for<'a> rkyv::bytecheck::CheckBytes>, { type Utility = UnsizedIndexPageUtility; @@ -78,20 +79,22 @@ where let mut slot_size_bytes = vec![0u8; UnsizedIndexPageUtility::::slots_size_size()]; file.read_exact(slot_size_bytes.as_mut_slice()).await?; - let archived = unsafe { - rkyv::access_unchecked::<::Archived>( - &slot_size_bytes[0..UnsizedIndexPageUtility::::slots_size_size()], - ) - }; + // Validated: these two length fields steer every later read of the + // page, so a torn page must fail here rather than misdirect them. + let archived = rkyv::access::<::Archived, rkyv::rancor::Error>( + &slot_size_bytes[0..UnsizedIndexPageUtility::::slots_size_size()], + ) + .map_err(|error| eyre::eyre!("torn or corrupt unsized index page (slots size): {error}"))?; let slots_size = rkyv::deserialize::(archived).expect("data should be valid"); let mut node_id_size_bytes = vec![0u8; UnsizedIndexPageUtility::::node_id_size_size()]; file.read_exact(node_id_size_bytes.as_mut_slice()).await?; - let archived = unsafe { - rkyv::access_unchecked::<::Archived>( - &node_id_size_bytes[0..UnsizedIndexPageUtility::::node_id_size_size()], - ) - }; + let archived = rkyv::access::<::Archived, rkyv::rancor::Error>( + &node_id_size_bytes[0..UnsizedIndexPageUtility::::node_id_size_size()], + ) + .map_err(|error| { + eyre::eyre!("torn or corrupt unsized index page (node id size): {error}") + })?; let node_id_size = rkyv::deserialize::(archived).expect("data should be valid"); @@ -122,7 +125,8 @@ where + for<'a> Serialize< Strategy, Share>, rkyv::rancor::Error>, >, - ::Archived: Deserialize>, + ::Archived: Deserialize> + + for<'a> rkyv::bytecheck::CheckBytes>, { pub fn new(node_id: IndexValue) -> eyre::Result { let len = node_id.aligned_size() as u32; @@ -203,7 +207,10 @@ where + for<'a> Serialize< Strategy, Share>, rkyv::rancor::Error>, >, - ::Archived: Deserialize>, + ::Archived: Deserialize> + + for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, { // We seek to page's end and will write values from tail. seek_to_page_start(file, page_id.0 + 1).await?; @@ -219,14 +226,22 @@ where async fn read_value(file: &mut File, len: u16) -> eyre::Result> where T: Archive, - ::Archived: Deserialize>, + ::Archived: Deserialize> + + for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, + as Archive>::Archived: for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, { let mut bytes = vec![0u8; len as usize]; file.read_exact(bytes.as_mut_slice()).await?; let mut v = AlignedVec::<4>::new(); v.extend_from_slice(bytes.as_slice()); + // Validated: a torn index entry must be an error, not a dangling link. let archived = - unsafe { rkyv::access_unchecked::< as Archive>::Archived>(&v[..]) }; + rkyv::access::< as Archive>::Archived, rkyv::rancor::Error>(&v[..]) + .map_err(|error| eyre::eyre!("torn or corrupt unsized index entry: {error}"))?; Ok(rkyv::deserialize(archived).expect("data should be valid")) } @@ -238,7 +253,13 @@ where ) -> eyre::Result> where T: Archive, - ::Archived: Deserialize>, + ::Archived: Deserialize> + + for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, + as Archive>::Archived: for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, { seek_to_page_start(file, page_id.0 + 1).await?; file.seek(SeekFrom::Current(-(offset as i64))).await?; @@ -276,7 +297,11 @@ where + for<'a> Serialize< Strategy, Share>, rkyv::rancor::Error>, >, - ::Archived: Deserialize>, + ::Archived: Deserialize> + + for<'a> rkyv::bytecheck::CheckBytes>, + as Archive>::Archived: for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, { fn as_bytes(&self) -> impl AsRef<[u8]> + Send { let data_length = DATA_LENGTH as usize; @@ -305,16 +330,21 @@ where } fn from_bytes(bytes: &[u8], _version: u32) -> Self { + // Validated throughout: `from_bytes` has no error channel, so a torn + // page becomes a named panic here instead of undefined behavior in + // whatever walks the misread entries later. let slots_size_bytes = &bytes[0..UnsizedIndexPageUtility::::slots_size_size()]; let archived = - unsafe { rkyv::access_unchecked::<::Archived>(slots_size_bytes) }; + rkyv::access::<::Archived, rkyv::rancor::Error>(slots_size_bytes) + .expect("torn or corrupt unsized index page: slots size fails validation"); let slots_size = rkyv::deserialize::(archived).expect("data should be valid"); let node_id_size_bytes = &bytes[UnsizedIndexPageUtility::::slots_size_size() ..UnsizedIndexPageUtility::::node_id_size_size() + UnsizedIndexPageUtility::::node_id_size_size()]; let archived = - unsafe { rkyv::access_unchecked::<::Archived>(node_id_size_bytes) }; + rkyv::access::<::Archived, rkyv::rancor::Error>(node_id_size_bytes) + .expect("torn or corrupt unsized index page: node id size fails validation"); let node_id_size = rkyv::deserialize::(archived).expect("data should be valid"); let utility_len = UnsizedIndexPageUtility::::persisted_size( @@ -327,9 +357,11 @@ where let offset = bytes.len() - *offset as usize; let len = *len as usize; let value_bytes = &bytes[offset..(offset + len)]; - let archived = unsafe { - rkyv::access_unchecked::< as Archive>::Archived>(value_bytes) - }; + let archived = + rkyv::access::< as Archive>::Archived, rkyv::rancor::Error>( + value_bytes, + ) + .expect("torn or corrupt unsized index page: an entry fails validation"); let val = rkyv::deserialize::<_, rkyv::rancor::Error>(archived) .expect("data should be valid"); index_values.push(val) diff --git a/src/page/index/page_for_unsized_cdc_impl.rs b/src/page/index/page_for_unsized_cdc_impl.rs index 33e29d4..2ecc4c3 100644 --- a/src/page/index/page_for_unsized_cdc_impl.rs +++ b/src/page/index/page_for_unsized_cdc_impl.rs @@ -26,7 +26,8 @@ where + for<'a> Serialize< Strategy, Share>, rkyv::rancor::Error>, >, - ::Archived: Deserialize>, + ::Archived: Deserialize> + + for<'a> rkyv::bytecheck::CheckBytes>, { pub fn apply_change_event(&mut self, event: ChangeEvent>) -> eyre::Result<()> { match event { diff --git a/src/page/index/table_of_contents_page.rs b/src/page/index/table_of_contents_page.rs index 5488d19..30997b7 100644 --- a/src/page/index/table_of_contents_page.rs +++ b/src/page/index/table_of_contents_page.rs @@ -49,6 +49,10 @@ where >, ::Archived: rkyv::Deserialize> + Ord, + as rkyv::Archive>::Archived: + for<'a> rkyv::bytecheck::CheckBytes< + rkyv::api::high::HighValidator<'a, rkyv::rancor::Error>, + >, { fn as_bytes(&self) -> impl AsRef<[u8]> { let records = self @@ -64,9 +68,13 @@ where rkyv::to_bytes::(&model).unwrap() } fn from_bytes(bytes: &[u8], _version: u32) -> Self { - let archived = unsafe { - rkyv::access_unchecked::< as Archive>::Archived>(bytes) - }; + // Validated: the table of contents is the map every other read + // trusts, so a torn one must fail loudly here. + let archived = rkyv::access::< + as Archive>::Archived, + rkyv::rancor::Error, + >(bytes) + .expect("torn or corrupt table of contents page: the bytes fail validation"); let model: TableOfContentsPagePersisted = rkyv::deserialize::<_, rkyv::rancor::Error>(archived).expect("data should be valid"); let records = BTreeMap::from_iter(model.records); diff --git a/src/page/iterators.rs b/src/page/iterators.rs index aa89a98..0cd090b 100644 --- a/src/page/iterators.rs +++ b/src/page/iterators.rs @@ -44,13 +44,14 @@ impl<'a> LinksIterator<'a> { fn parse_links(buffer: &[u8]) -> Vec where T: Archive, + as Archive>::Archived: for<'a> rkyv::bytecheck::CheckBytes>, [ArchivedIndexValue]: DeserializeUnsized<[IndexValue], Strategy> { - let archived = unsafe { - rkyv::access_unchecked::< as Archive>::Archived>( - &buffer[..], - ) - }; + // Validated: these bytes are read straight off disk, and a torn index + // page must fail loudly here rather than dangle links into nowhere. + let archived = + rkyv::access::< as Archive>::Archived, rkyv::rancor::Error>(&buffer[..]) + .expect("torn or corrupt index page: the archived bytes fail validation"); let index_records = rkyv::deserialize::, rkyv::rancor::Error>(archived) .expect("data should be valid") diff --git a/src/page/space_info.rs b/src/page/space_info.rs index 34ed961..67158af 100644 --- a/src/page/space_info.rs +++ b/src/page/space_info.rs @@ -116,8 +116,8 @@ where rkyv::rancor::Error, >, >, - ::Archived: - rkyv::Deserialize>, + ::Archived: rkyv::Deserialize> + + for<'a> rkyv::bytecheck::CheckBytes>, { fn as_bytes(&self) -> impl AsRef<[u8]> + Send { let v2 = SpaceInfoPageV2 { diff --git a/src/page/util.rs b/src/page/util.rs index 654f291..dd78d6e 100644 --- a/src/page/util.rs +++ b/src/page/util.rs @@ -148,10 +148,13 @@ pub async fn update_at( pub async fn parse_general_header(file: &mut File) -> eyre::Result { let mut buffer = [0; GENERAL_HEADER_SIZE]; file.read_exact(&mut buffer).await?; + // Validated: a header torn by a mid-write death must surface as an error + // naming the page, not as undefined behavior in whatever reads it next. let archived = - unsafe { rkyv::access_unchecked::<::Archived>(&buffer[..]) }; - let header = - rkyv::deserialize::<_, rkyv::rancor::Error>(archived).expect("data should be valid"); + rkyv::access::<::Archived, rkyv::rancor::Error>(&buffer[..]) + .map_err(|error| eyre::eyre!("torn or corrupt page header: {error}"))?; + let header = rkyv::deserialize::<_, rkyv::rancor::Error>(archived) + .map_err(|error| eyre::eyre!("page header failed to deserialize: {error}"))?; Ok(header) } diff --git a/src/util/persistable.rs b/src/util/persistable.rs index 951caaf..dbea6dd 100644 --- a/src/util/persistable.rs +++ b/src/util/persistable.rs @@ -1,5 +1,7 @@ use crate::SizeMeasurable; +use rkyv::api::high::HighValidator; +use rkyv::bytecheck::CheckBytes; use rkyv::de::Pool; use rkyv::rancor::Strategy; use rkyv::ser::allocator::ArenaHandle; @@ -13,6 +15,28 @@ pub trait Persistable { fn from_bytes(bytes: &[u8], version: u32) -> Self; } +/* + * Validated access, not `access_unchecked`. These bytes come off disk, and a + * process that died mid-write (crash, SIGKILL, an undrained exit) leaves torn + * pages behind: unchecked access reads a torn page as an archived value whose + * relative pointers dangle anywhere, and the process dies of SIGBUS in + * whatever touches them next — usually mid-write, tearing the store further. + * Validation turns the same bytes into a named panic at the parse site, + * while the store on disk stays exactly as readable as it was. + */ +pub(crate) fn checked(bytes: &[u8]) -> T +where + T: Archive, + ::Archived: rkyv::Portable + + for<'a> CheckBytes> + + Deserialize>, +{ + let archived = rkyv::access::<::Archived, rkyv::rancor::Error>(bytes) + .expect("torn or corrupt page: the archived bytes fail validation"); + rkyv::deserialize::<_, rkyv::rancor::Error>(archived) + .expect("validated archive failed to deserialize") +} + impl Persistable for Vec where T: Archive @@ -21,15 +45,15 @@ where > + Default + SizeMeasurable + Clone, - ::Archived: Deserialize>, + ::Archived: Deserialize> + + for<'a> CheckBytes>, { fn as_bytes(&self) -> impl AsRef<[u8]> { rkyv::to_bytes::(self).unwrap() } fn from_bytes(bytes: &[u8], _version: u32) -> Self { - let archived = unsafe { rkyv::access_unchecked::<::Archived>(bytes) }; - rkyv::deserialize::<_, rkyv::rancor::Error>(archived).expect("data should be valid") + checked::(bytes) } } @@ -39,8 +63,7 @@ impl Persistable for u8 { } fn from_bytes(bytes: &[u8], _version: u32) -> Self { - let archived = unsafe { rkyv::access_unchecked::<::Archived>(bytes) }; - rkyv::deserialize::<_, rkyv::rancor::Error>(archived).expect("data should be valid") + checked::(bytes) } } @@ -50,7 +73,6 @@ impl Persistable for String { } fn from_bytes(bytes: &[u8], _version: u32) -> Self { - let archived = unsafe { rkyv::access_unchecked::<::Archived>(bytes) }; - rkyv::deserialize::<_, rkyv::rancor::Error>(archived).expect("data should be valid") + checked::(bytes) } } From 48eae4b5da68d99bc2e1a5e878ada49cddff3840 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 1 Aug 2026 20:20:31 +0700 Subject: [PATCH 2/2] feat: validate-reads is a default feature, off for latency-critical builds This crate also runs at nanosecond scale, where even background-task CPU is budgeted and a laptop benchmark proves nothing. So the safety and the speed stop competing: every disk read routes through one switch, access_archived, which validates with bytecheck under the default validate-reads feature and compiles back to the exact access_unchecked it was before under default-features = false. The CheckBytes bounds stay unconditional so the API does not shift under the flag; derived Archive types satisfy them for free. The derive templates emit calls to the switch, and the crate aliases itself so its own derives resolve the path. Both configurations build, test (39 each) and lint clean. --- Cargo.toml | 7 ++++ .../persistable/generator/persistable_impl.rs | 12 +++--- src/lib.rs | 5 +++ src/page/index/page.rs | 7 ++-- src/page/index/page_for_unsized.rs | 25 +++++------- src/page/index/table_of_contents_page.rs | 8 ++-- src/page/iterators.rs | 2 +- src/page/util.rs | 5 +-- src/util/mod.rs | 2 +- src/util/persistable.rs | 40 +++++++++++++++---- 10 files changed, 70 insertions(+), 43 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4895cda..e7b26cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,10 @@ indexset = { package = "WorkTablesIndex", version = "=0.0.1", features = ["concu # indexset = { package = "wt-indexset", path = "../indexset", version = "0.12.10", features = ["concurrent", "cdc", "multimap"] } # indexset = { package = "wt-indexset", version = "0.12.12", features = ["concurrent", "cdc", "multimap"] } tokio = { version = "1", features = ["full"] } + +[features] +default = ["validate-reads"] +# Validate every disk read with bytecheck: a torn page becomes a named +# error instead of undefined behavior. Disable for latency-critical builds +# to compile every read back to unchecked access, exactly as before 0.4.1. +validate-reads = [] diff --git a/codegen/src/persistable/generator/persistable_impl.rs b/codegen/src/persistable/generator/persistable_impl.rs index 90137b5..ba0a259 100644 --- a/codegen/src/persistable/generator/persistable_impl.rs +++ b/codegen/src/persistable/generator/persistable_impl.rs @@ -90,7 +90,7 @@ impl Generator { } fn from_bytes(bytes: &[u8], _version: u32) -> Self { - let archived = rkyv::access::<::Archived, rkyv::rancor::Error>(bytes).expect("torn or corrupt page: the archived bytes fail validation"); + let archived = data_bucket::access_archived::<::Archived>(bytes).expect("torn or corrupt page: the archived bytes fail validation"); rkyv::deserialize::<_, rkyv::rancor::Error>(archived).expect("data should be valid") } } @@ -214,7 +214,7 @@ impl Generator { quote! { let size_length = <#size_type as Default>::default().aligned_size(); let archived = - rkyv::access::<<#size_type as Archive>::Archived, rkyv::rancor::Error>(&bytes[offset..offset + size_length]).expect("torn or corrupt page part: a size field fails validation"); + data_bucket::access_archived::<<#size_type as Archive>::Archived>(&bytes[offset..offset + size_length]).expect("torn or corrupt page part: a size field fails validation"); let #size_ident = rkyv::deserialize::<#size_type, rkyv::rancor::Error>(archived).expect("data should be valid"); offset += size_length; @@ -240,7 +240,7 @@ impl Generator { let length = <#ty as Default>::default().aligned_size(); let mut v = rkyv::util::AlignedVec::<4>::new(); v.extend_from_slice(&bytes[offset..offset + length]); - let archived = rkyv::access::<<#ty as Archive>::Archived, rkyv::rancor::Error>(&v[..]).expect("torn or corrupt page part: a field fails validation"); + let archived = data_bucket::access_archived::<<#ty as Archive>::Archived>(&v[..]).expect("torn or corrupt page part: a field fails validation"); let #ident = rkyv::deserialize::<_, rkyv::rancor::Error>(archived).expect("data should be valid"); offset += length; } @@ -283,7 +283,7 @@ impl Generator { let mut v = rkyv::util::AlignedVec::<4>::new(); v.extend_from_slice(&bytes[offset..offset + values_len]); let archived = - rkyv::access::<<#ty as Archive>::Archived, rkyv::rancor::Error>(&v[..]).expect("torn or corrupt page part: a field fails validation"); + data_bucket::access_archived::<<#ty as Archive>::Archived>(&v[..]).expect("torn or corrupt page part: a field fails validation"); let #ident = rkyv::deserialize::<#ty, rkyv::rancor::Error>(archived) .expect("data should be valid"); offset += values_len; @@ -313,7 +313,7 @@ impl Generator { let mut v = rkyv::util::AlignedVec::<4>::new(); v.extend_from_slice(&bytes[offset..offset + values_len]); let archived = - rkyv::access::<<#ty as Archive>::Archived, rkyv::rancor::Error>(&v[..]).expect("torn or corrupt page part: a field fails validation"); + data_bucket::access_archived::<<#ty as Archive>::Archived>(&v[..]).expect("torn or corrupt page part: a field fails validation"); let #ident = rkyv::deserialize::<#ty, rkyv::rancor::Error>(archived) .expect("data should be valid"); offset += values_len; @@ -343,7 +343,7 @@ impl Generator { let mut v = rkyv::util::AlignedVec::<4>::new(); v.extend_from_slice(&bytes[offset..offset + values_len]); let archived = - rkyv::access::<<#ty as Archive>::Archived, rkyv::rancor::Error>(&v[..]).expect("torn or corrupt page part: a field fails validation"); + data_bucket::access_archived::<<#ty as Archive>::Archived>(&v[..]).expect("torn or corrupt page part: a field fails validation"); let #ident = rkyv::deserialize::<#ty, rkyv::rancor::Error>(archived) .expect("data should be valid"); offset += values_len; diff --git a/src/lib.rs b/src/lib.rs index ca8fd96..199b56d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,9 @@ extern crate core; +// The Persistable derive emits paths through the crate name, and this crate +// uses its own derive: alias ourselves so the generated code resolves here too. +extern crate self as data_bucket; + pub mod link; pub mod page; pub mod persistence; @@ -19,4 +23,5 @@ pub use page::{ }; pub use persistence::{PersistableIndex, PersistableTable}; pub use space::Id as SpaceId; +pub use util::access_archived; pub use util::{align, align8, align_vec, Persistable, SizeMeasurable, VariableSizeMeasurable}; diff --git a/src/page/index/page.rs b/src/page/index/page.rs index c39ebfb..d2f0b24 100644 --- a/src/page/index/page.rs +++ b/src/page/index/page.rs @@ -96,7 +96,7 @@ where file.read_exact(size_bytes.as_mut_slice()).await?; // Validated: this length field steers how much of the page is read // as index entries, so a torn page must fail here, loudly. - let archived = rkyv::access::<::Archived, rkyv::rancor::Error>( + let archived = crate::access_archived::<::Archived>( &size_bytes[0..SizedIndexPageUtility::::size_size()], ) .map_err(|error| eyre::eyre!("torn or corrupt index page size field: {error}"))?; @@ -175,9 +175,8 @@ impl IndexPage { let mut v = AlignedVec::<4>::new(); v.extend_from_slice(bytes.as_slice()); // Validated: a torn index entry must be an error, not a dangling link. - let archived = - rkyv::access::< as Archive>::Archived, rkyv::rancor::Error>(&v[..]) - .map_err(|error| eyre::eyre!("torn or corrupt index entry: {error}"))?; + let archived = crate::access_archived::< as Archive>::Archived>(&v[..]) + .map_err(|error| eyre::eyre!("torn or corrupt index entry: {error}"))?; Ok(rkyv::deserialize(archived).expect("data should be valid")) } diff --git a/src/page/index/page_for_unsized.rs b/src/page/index/page_for_unsized.rs index ba9e2b6..573bc15 100644 --- a/src/page/index/page_for_unsized.rs +++ b/src/page/index/page_for_unsized.rs @@ -81,7 +81,7 @@ where file.read_exact(slot_size_bytes.as_mut_slice()).await?; // Validated: these two length fields steer every later read of the // page, so a torn page must fail here rather than misdirect them. - let archived = rkyv::access::<::Archived, rkyv::rancor::Error>( + let archived = crate::access_archived::<::Archived>( &slot_size_bytes[0..UnsizedIndexPageUtility::::slots_size_size()], ) .map_err(|error| eyre::eyre!("torn or corrupt unsized index page (slots size): {error}"))?; @@ -89,7 +89,7 @@ where rkyv::deserialize::(archived).expect("data should be valid"); let mut node_id_size_bytes = vec![0u8; UnsizedIndexPageUtility::::node_id_size_size()]; file.read_exact(node_id_size_bytes.as_mut_slice()).await?; - let archived = rkyv::access::<::Archived, rkyv::rancor::Error>( + let archived = crate::access_archived::<::Archived>( &node_id_size_bytes[0..UnsizedIndexPageUtility::::node_id_size_size()], ) .map_err(|error| { @@ -239,9 +239,8 @@ where let mut v = AlignedVec::<4>::new(); v.extend_from_slice(bytes.as_slice()); // Validated: a torn index entry must be an error, not a dangling link. - let archived = - rkyv::access::< as Archive>::Archived, rkyv::rancor::Error>(&v[..]) - .map_err(|error| eyre::eyre!("torn or corrupt unsized index entry: {error}"))?; + let archived = crate::access_archived::< as Archive>::Archived>(&v[..]) + .map_err(|error| eyre::eyre!("torn or corrupt unsized index entry: {error}"))?; Ok(rkyv::deserialize(archived).expect("data should be valid")) } @@ -334,17 +333,15 @@ where // page becomes a named panic here instead of undefined behavior in // whatever walks the misread entries later. let slots_size_bytes = &bytes[0..UnsizedIndexPageUtility::::slots_size_size()]; - let archived = - rkyv::access::<::Archived, rkyv::rancor::Error>(slots_size_bytes) - .expect("torn or corrupt unsized index page: slots size fails validation"); + let archived = crate::access_archived::<::Archived>(slots_size_bytes) + .expect("torn or corrupt unsized index page: slots size fails validation"); let slots_size = rkyv::deserialize::(archived).expect("data should be valid"); let node_id_size_bytes = &bytes[UnsizedIndexPageUtility::::slots_size_size() ..UnsizedIndexPageUtility::::node_id_size_size() + UnsizedIndexPageUtility::::node_id_size_size()]; - let archived = - rkyv::access::<::Archived, rkyv::rancor::Error>(node_id_size_bytes) - .expect("torn or corrupt unsized index page: node id size fails validation"); + let archived = crate::access_archived::<::Archived>(node_id_size_bytes) + .expect("torn or corrupt unsized index page: node id size fails validation"); let node_id_size = rkyv::deserialize::(archived).expect("data should be valid"); let utility_len = UnsizedIndexPageUtility::::persisted_size( @@ -358,10 +355,8 @@ where let len = *len as usize; let value_bytes = &bytes[offset..(offset + len)]; let archived = - rkyv::access::< as Archive>::Archived, rkyv::rancor::Error>( - value_bytes, - ) - .expect("torn or corrupt unsized index page: an entry fails validation"); + crate::access_archived::< as Archive>::Archived>(value_bytes) + .expect("torn or corrupt unsized index page: an entry fails validation"); let val = rkyv::deserialize::<_, rkyv::rancor::Error>(archived) .expect("data should be valid"); index_values.push(val) diff --git a/src/page/index/table_of_contents_page.rs b/src/page/index/table_of_contents_page.rs index 30997b7..0ee6988 100644 --- a/src/page/index/table_of_contents_page.rs +++ b/src/page/index/table_of_contents_page.rs @@ -70,11 +70,9 @@ where fn from_bytes(bytes: &[u8], _version: u32) -> Self { // Validated: the table of contents is the map every other read // trusts, so a torn one must fail loudly here. - let archived = rkyv::access::< - as Archive>::Archived, - rkyv::rancor::Error, - >(bytes) - .expect("torn or corrupt table of contents page: the bytes fail validation"); + let archived = + crate::access_archived::< as Archive>::Archived>(bytes) + .expect("torn or corrupt table of contents page: the bytes fail validation"); let model: TableOfContentsPagePersisted = rkyv::deserialize::<_, rkyv::rancor::Error>(archived).expect("data should be valid"); let records = BTreeMap::from_iter(model.records); diff --git a/src/page/iterators.rs b/src/page/iterators.rs index 0cd090b..8795c23 100644 --- a/src/page/iterators.rs +++ b/src/page/iterators.rs @@ -50,7 +50,7 @@ where T: Archive, // Validated: these bytes are read straight off disk, and a torn index // page must fail loudly here rather than dangle links into nowhere. let archived = - rkyv::access::< as Archive>::Archived, rkyv::rancor::Error>(&buffer[..]) + crate::access_archived::< as Archive>::Archived>(&buffer[..]) .expect("torn or corrupt index page: the archived bytes fail validation"); let index_records = rkyv::deserialize::, rkyv::rancor::Error>(archived) diff --git a/src/page/util.rs b/src/page/util.rs index dd78d6e..617cf23 100644 --- a/src/page/util.rs +++ b/src/page/util.rs @@ -150,9 +150,8 @@ pub async fn parse_general_header(file: &mut File) -> eyre::Result::Archived, rkyv::rancor::Error>(&buffer[..]) - .map_err(|error| eyre::eyre!("torn or corrupt page header: {error}"))?; + let archived = crate::access_archived::<::Archived>(&buffer[..]) + .map_err(|error| eyre::eyre!("torn or corrupt page header: {error}"))?; let header = rkyv::deserialize::<_, rkyv::rancor::Error>(archived) .map_err(|error| eyre::eyre!("page header failed to deserialize: {error}"))?; diff --git a/src/util/mod.rs b/src/util/mod.rs index 84a1a24..2b05af2 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,5 +1,5 @@ mod persistable; mod sized; -pub use persistable::Persistable; +pub use persistable::{access_archived, Persistable}; pub use sized::{align, align8, align_vec, SizeMeasurable, VariableSizeMeasurable}; diff --git a/src/util/persistable.rs b/src/util/persistable.rs index dbea6dd..d9f1680 100644 --- a/src/util/persistable.rs +++ b/src/util/persistable.rs @@ -16,14 +16,38 @@ pub trait Persistable { } /* - * Validated access, not `access_unchecked`. These bytes come off disk, and a - * process that died mid-write (crash, SIGKILL, an undrained exit) leaves torn - * pages behind: unchecked access reads a torn page as an archived value whose - * relative pointers dangle anywhere, and the process dies of SIGBUS in - * whatever touches them next — usually mid-write, tearing the store further. - * Validation turns the same bytes into a named panic at the parse site, - * while the store on disk stays exactly as readable as it was. + * The one switch every disk read goes through. These bytes come off disk, + * and a process that died mid-write (crash, SIGKILL, an undrained exit) + * leaves torn pages behind: unchecked access reads a torn page as an + * archived value whose relative pointers dangle anywhere, and the process + * dies of SIGBUS in whatever touches them next, usually mid-write, tearing + * the store further. With the default `validate-reads` feature the same + * bytes become a named error at the parse site instead, while the store on + * disk stays exactly as readable as it was. + * + * `validate-reads` is a default feature rather than unconditional because + * this crate also runs at nanosecond scale, where even background-task CPU + * is budgeted: `default-features = false` compiles every read back to the + * exact `access_unchecked` it was before, zero cost, caveat emptor. The + * CheckBytes bounds stay unconditional either way so the API surface does + * not shift under a feature flag; derived Archive types satisfy them for + * free. */ +#[inline] +pub fn access_archived(bytes: &[u8]) -> Result<&A, rkyv::rancor::Error> +where + A: rkyv::Portable + for<'a> CheckBytes>, +{ + #[cfg(feature = "validate-reads")] + { + rkyv::access::(bytes) + } + #[cfg(not(feature = "validate-reads"))] + { + Ok(unsafe { rkyv::access_unchecked::(bytes) }) + } +} + pub(crate) fn checked(bytes: &[u8]) -> T where T: Archive, @@ -31,7 +55,7 @@ where + for<'a> CheckBytes> + Deserialize>, { - let archived = rkyv::access::<::Archived, rkyv::rancor::Error>(bytes) + let archived = access_archived::<::Archived>(bytes) .expect("torn or corrupt page: the archived bytes fail validation"); rkyv::deserialize::<_, rkyv::rancor::Error>(archived) .expect("validated archive failed to deserialize")