btrfs_uapi/tree_search.rs
1//! # Generic B-tree search: walking any internal btrfs tree via `BTRFS_IOC_TREE_SEARCH`
2//!
3//! The kernel's tree search ioctl lets userspace read any internal btrfs tree
4//! (chunk, root, quota, …) by specifying a key range. Items are returned in
5//! batches; [`tree_search`] advances the cursor automatically and calls a
6//! closure once per item until the range is exhausted.
7//!
8//! # Byte order
9//!
10//! [`SearchHeader`] fields (objectid, offset, type) are in host byte order:
11//! the kernel fills them in through the ioctl layer. The `data` slice passed
12//! to the callback contains the raw on-disk item payload, which is
13//! **little-endian**; callers must use `u64::from_le_bytes` and friends when
14//! interpreting it.
15//!
16//! # Ioctl version
17//!
18//! This module provides three variants:
19//!
20//! - [`tree_search`] uses `BTRFS_IOC_TREE_SEARCH` (v1) with its fixed
21//! 3992-byte result buffer. Sufficient for all item types used by this crate.
22//! - [`tree_search_v2`] uses `BTRFS_IOC_TREE_SEARCH_V2` with a caller-chosen
23//! buffer size. Useful when items may be larger than what v1 can return in a
24//! single batch.
25//! - [`tree_search_auto`] tries v2 first and transparently falls back to v1
26//! when the underlying driver doesn't support v2 — currently FUSE drivers
27//! like our own `btrfs-fuse` (the indirected-buffer pattern can't survive
28//! a FUSE round trip; signalled with `ENOPROTOOPT`) and very old kernels
29//! that pre-date v2 (`ENOTTY` / `ENOSYS`). Use this when you don't care
30//! which path runs, only that the search completes.
31
32use crate::raw::{
33 btrfs_ioc_tree_search, btrfs_ioc_tree_search_v2, btrfs_ioctl_search_args,
34 btrfs_ioctl_search_args_v2, btrfs_ioctl_search_header,
35 btrfs_ioctl_search_key,
36};
37use std::{
38 mem,
39 os::{fd::AsRawFd, unix::io::BorrowedFd},
40};
41
42/// A compound B-tree key: `(objectid, item_type, offset)`.
43///
44/// Items in a btrfs tree are ordered by this 136-bit compound value:
45/// `(objectid << 72) | (item_type << 64) | offset`.
46/// The three fields are not independent ranges — they form a single
47/// ordered tuple.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct Key {
50 /// Object this key belongs to (e.g. inode number, tree ID, device ID).
51 pub objectid: u64,
52 /// Item type: a `BTRFS_*_KEY` constant from `crate::raw`.
53 pub item_type: u32,
54 /// Type-specific offset (e.g. byte offset for extents, parent ID for
55 /// backrefs).
56 pub offset: u64,
57}
58
59impl Key {
60 /// The smallest possible key.
61 pub const MIN: Self = Self {
62 objectid: 0,
63 item_type: 0,
64 offset: 0,
65 };
66
67 /// The largest possible key.
68 pub const MAX: Self = Self {
69 objectid: u64::MAX,
70 item_type: u32::MAX,
71 offset: u64::MAX,
72 };
73}
74
75/// Filter specifying which items to return from a tree search.
76///
77/// Filtering works in three stages:
78///
79/// 1. Select a tree by `tree_id` (e.g. root tree, chunk tree, quota tree).
80/// 2. Return only items whose compound key `(objectid, item_type, offset)`
81/// falls within the inclusive range `[start, end]`. Because the key is a
82/// compound tuple, the three components of `start` and `end` are not
83/// independent filters — they form a single ordered bound on the B-tree.
84/// This means items with unexpected types can appear if their compound key
85/// is between `start` and `end`; callbacks should filter on
86/// `hdr.item_type` when they need a single type.
87/// 3. Trim results to only include items stored in metadata blocks whose
88/// transaction ID falls within `[min_transid, max_transid]`.
89///
90/// Build a filter for common cases with [`SearchFilter::for_type`] or
91/// [`SearchFilter::for_objectid_range`].
92///
93/// Tree IDs and item type codes are the `BTRFS_*_OBJECTID` and `BTRFS_*_KEY`
94/// constants from `crate::raw`, cast to `u64` and `u32` respectively at the
95/// call site.
96#[derive(Debug, Clone)]
97pub struct SearchFilter {
98 /// Tree to search: a `BTRFS_*_TREE_OBJECTID` constant from `crate::raw`.
99 pub tree_id: u64,
100 /// Lower bound of the key range (inclusive).
101 pub start: Key,
102 /// Upper bound of the key range (inclusive).
103 pub end: Key,
104 /// Lower bound on the metadata block transaction ID (inclusive).
105 pub min_transid: u64,
106 /// Upper bound on the metadata block transaction ID (inclusive).
107 pub max_transid: u64,
108}
109
110impl SearchFilter {
111 /// Return all items of `item_type` in `tree_id`, across every objectid
112 /// and offset.
113 #[must_use]
114 pub fn for_type(tree_id: u64, item_type: u32) -> Self {
115 Self {
116 tree_id,
117 start: Key {
118 objectid: 0,
119 item_type,
120 offset: 0,
121 },
122 end: Key {
123 objectid: u64::MAX,
124 item_type,
125 offset: u64::MAX,
126 },
127 min_transid: 0,
128 max_transid: u64::MAX,
129 }
130 }
131
132 /// Return all items of `item_type` in `tree_id` whose objectid falls in
133 /// `[min_objectid, max_objectid]`.
134 #[must_use]
135 pub fn for_objectid_range(
136 tree_id: u64,
137 item_type: u32,
138 min_objectid: u64,
139 max_objectid: u64,
140 ) -> Self {
141 Self {
142 tree_id,
143 start: Key {
144 objectid: min_objectid,
145 item_type,
146 offset: 0,
147 },
148 end: Key {
149 objectid: max_objectid,
150 item_type,
151 offset: u64::MAX,
152 },
153 min_transid: 0,
154 max_transid: u64::MAX,
155 }
156 }
157}
158
159/// Metadata returned for each item found by [`tree_search`].
160///
161/// The header fields are in host byte order (the kernel fills them in through
162/// the ioctl layer). The accompanying `data` slice passed to the callback is
163/// the raw on-disk item payload and is in **little-endian** byte order.
164#[derive(Debug, Clone, Copy)]
165pub struct SearchHeader {
166 /// Transaction ID of the metadata block that contains this item.
167 pub transid: u64,
168 /// Object ID portion of the item's btrfs key.
169 pub objectid: u64,
170 /// Offset portion of the item's btrfs key.
171 pub offset: u64,
172 /// Item type (the `type` field of the btrfs key).
173 pub item_type: u32,
174 /// Length in bytes of the item's data payload.
175 pub len: u32,
176}
177
178/// Number of items to request per ioctl call.
179const ITEMS_PER_BATCH: u32 = 4096;
180
181/// Size of `btrfs_ioctl_search_header` as a compile-time constant.
182const SEARCH_HEADER_SIZE: usize = mem::size_of::<btrfs_ioctl_search_header>();
183
184/// Walk every item in the tree that falls within `key`, calling `f` once for
185/// each one.
186///
187/// `f` receives:
188/// * a reference to the item's [`SearchHeader`] (objectid, offset, type, …)
189/// * a byte slice of the item's raw on-disk data payload (little-endian)
190///
191/// The search stops early and the error is returned if:
192/// * `f` returns `Err(_)`
193/// * the underlying `BTRFS_IOC_TREE_SEARCH` ioctl fails
194///
195/// Returns `Ok(())` when the entire requested range has been scanned.
196///
197/// **Important:** the kernel treats `(min_objectid, min_type, min_offset)` and
198/// `(max_objectid, max_type, max_offset)` as compound tuple keys, not three
199/// independent range filters. This means items whose type falls outside
200/// `[min_type, max_type]` CAN be returned when their compound key is between
201/// the min and max bounds (e.g. a type-144 item with a lower objectid than a
202/// type-132 item with a higher objectid). Callbacks that need a single item
203/// type must filter on `hdr.item_type` themselves.
204///
205/// # Errors
206///
207/// Returns `Err` if the ioctl fails or the callback returns an error.
208///
209/// # Privileges
210///
211/// Most trees require `CAP_SYS_ADMIN`. The subvolume tree of the inode
212/// belonging to `fd` can be searched without elevated privileges by setting
213/// `key.tree_id = 0`.
214#[allow(clippy::needless_pass_by_value)] // SearchFilter is small but not Copy by design
215pub fn tree_search(
216 fd: BorrowedFd,
217 filter: SearchFilter,
218 mut f: impl FnMut(&SearchHeader, &[u8]) -> nix::Result<()>,
219) -> nix::Result<()> {
220 let mut args: btrfs_ioctl_search_args = unsafe { mem::zeroed() };
221
222 fill_search_key(&mut args.key, &filter);
223
224 loop {
225 args.key.nr_items = ITEMS_PER_BATCH;
226
227 unsafe { btrfs_ioc_tree_search(fd.as_raw_fd(), &raw mut args) }?;
228
229 let nr = args.key.nr_items;
230 if nr == 0 {
231 break;
232 }
233
234 // Walk the result buffer. We use raw pointer reads to avoid borrow
235 // checker conflicts: args.buf (read-only after the ioctl) and
236 // args.key (mutated below for cursor advancement) are separate fields,
237 // but a Rust reference to one would prevent mutation of the other.
238 //
239 // SAFETY:
240 // * The ioctl has returned successfully, so args.buf contains nr valid
241 // (header, data) pairs totalling at most args.buf.len() bytes.
242 // * buf_base is derived from args.buf which lives for the entire loop
243 // body; it is not invalidated until args is dropped.
244 // * All raw reads are bounds-checked before dereferencing.
245 // * The `data` slices passed to `f` do not outlive this loop
246 // iteration — `f` takes `&[u8]`, not `&'static [u8]`.
247 let buf_base: *const u8 = args.buf.as_ptr().cast();
248 let buf_cap = args.buf.len();
249
250 let mut off = 0usize;
251 let mut last = SearchHeader {
252 transid: 0,
253 objectid: 0,
254 offset: 0,
255 item_type: 0,
256 len: 0,
257 };
258
259 for _ in 0..nr {
260 if off + SEARCH_HEADER_SIZE > buf_cap {
261 return Err(nix::errno::Errno::EOVERFLOW);
262 }
263 let raw_hdr: btrfs_ioctl_search_header = unsafe {
264 buf_base
265 .add(off)
266 .cast::<btrfs_ioctl_search_header>()
267 .read_unaligned()
268 };
269 let hdr = SearchHeader {
270 transid: raw_hdr.transid,
271 objectid: raw_hdr.objectid,
272 offset: raw_hdr.offset,
273 item_type: raw_hdr.type_,
274 len: raw_hdr.len,
275 };
276 off += SEARCH_HEADER_SIZE;
277
278 let data_len = hdr.len as usize;
279 if off + data_len > buf_cap {
280 return Err(nix::errno::Errno::EOVERFLOW);
281 }
282 let data: &[u8] = unsafe {
283 std::slice::from_raw_parts(buf_base.add(off), data_len)
284 };
285 off += data_len;
286
287 f(&hdr, data)?;
288 last = hdr;
289 }
290
291 if !advance_cursor(&mut args.key, &last) {
292 break;
293 }
294 }
295
296 Ok(())
297}
298
299/// Default buffer size for [`tree_search_v2`]: 64 KiB.
300const DEFAULT_V2_BUF_SIZE: usize = 64 * 1024;
301
302/// Like [`tree_search`] but uses `BTRFS_IOC_TREE_SEARCH_V2` with a larger
303/// result buffer.
304///
305/// `buf_size` controls the buffer size in bytes (default 64 KiB if `None`).
306/// The v2 ioctl is otherwise identical to v1 but can return more data per
307/// batch, reducing the number of round-trips for large result sets.
308///
309/// If `buf_size` is too small for even a single item, the kernel returns
310/// `EOVERFLOW` and sets `buf_size` to the required minimum. This function
311/// automatically retries with the larger buffer.
312///
313/// # Errors
314///
315/// Returns `Err` if the ioctl fails or the callback returns an error.
316#[allow(clippy::needless_pass_by_value)] // SearchFilter is small but not Copy by design
317#[allow(clippy::too_many_lines)]
318pub fn tree_search_v2(
319 fd: BorrowedFd,
320 filter: SearchFilter,
321 buf_size: Option<usize>,
322 mut f: impl FnMut(&SearchHeader, &[u8]) -> nix::Result<()>,
323) -> nix::Result<()> {
324 let base_size = mem::size_of::<btrfs_ioctl_search_args_v2>();
325 let mut capacity = buf_size.unwrap_or(DEFAULT_V2_BUF_SIZE);
326
327 // Allocate as Vec<u64> for 8-byte alignment.
328 let alloc_bytes = base_size + capacity;
329 let num_u64s = alloc_bytes.div_ceil(mem::size_of::<u64>());
330 let mut buf = vec![0u64; num_u64s];
331
332 // SAFETY: buf is correctly sized and aligned for btrfs_ioctl_search_args_v2.
333 let args_ptr = buf.as_mut_ptr().cast::<btrfs_ioctl_search_args_v2>();
334 unsafe {
335 fill_search_key(&mut (*args_ptr).key, &filter);
336 }
337
338 loop {
339 unsafe {
340 (*args_ptr).key.nr_items = ITEMS_PER_BATCH;
341 (*args_ptr).buf_size = capacity as u64;
342 }
343
344 match unsafe {
345 btrfs_ioc_tree_search_v2(fd.as_raw_fd(), &raw mut *args_ptr)
346 } {
347 Ok(_) => {}
348 Err(nix::errno::Errno::EOVERFLOW) => {
349 // Kernel tells us the needed size via buf_size.
350 #[allow(clippy::cast_possible_truncation)]
351 // buf_size fits in usize
352 let needed = unsafe { (*args_ptr).buf_size } as usize;
353 if needed <= capacity {
354 return Err(nix::errno::Errno::EOVERFLOW);
355 }
356 capacity = needed;
357 let alloc_bytes = base_size + capacity;
358 let num_u64s = alloc_bytes.div_ceil(mem::size_of::<u64>());
359 buf.resize(num_u64s, 0);
360 // args_ptr must be refreshed after reallocation.
361 let args_ptr_new =
362 buf.as_mut_ptr().cast::<btrfs_ioctl_search_args_v2>();
363 unsafe {
364 (*args_ptr_new).key.nr_items = ITEMS_PER_BATCH;
365 (*args_ptr_new).buf_size = capacity as u64;
366 btrfs_ioc_tree_search_v2(
367 fd.as_raw_fd(),
368 &raw mut *args_ptr_new,
369 )?;
370 }
371 // Fall through to process results with the new pointer.
372 // Update our local for the rest of the loop.
373 let _ = args_ptr_new;
374 }
375 Err(e) => return Err(e),
376 }
377
378 // Re-derive pointer after potential reallocation.
379 let args_ptr = buf.as_mut_ptr().cast::<btrfs_ioctl_search_args_v2>();
380
381 let nr = unsafe { (*args_ptr).key.nr_items };
382 if nr == 0 {
383 break;
384 }
385
386 // The result data starts right after the base struct (at the
387 // flexible array member `buf[]`).
388 let data_base: *const u8 =
389 unsafe { (args_ptr as *const u8).add(base_size) };
390
391 let mut off = 0usize;
392 let mut last = SearchHeader {
393 transid: 0,
394 objectid: 0,
395 offset: 0,
396 item_type: 0,
397 len: 0,
398 };
399
400 for _ in 0..nr {
401 if off + SEARCH_HEADER_SIZE > capacity {
402 return Err(nix::errno::Errno::EOVERFLOW);
403 }
404 let raw_hdr: btrfs_ioctl_search_header = unsafe {
405 data_base
406 .add(off)
407 .cast::<btrfs_ioctl_search_header>()
408 .read_unaligned()
409 };
410 let hdr = SearchHeader {
411 transid: raw_hdr.transid,
412 objectid: raw_hdr.objectid,
413 offset: raw_hdr.offset,
414 item_type: raw_hdr.type_,
415 len: raw_hdr.len,
416 };
417 off += SEARCH_HEADER_SIZE;
418
419 let data_len = hdr.len as usize;
420 if off + data_len > capacity {
421 return Err(nix::errno::Errno::EOVERFLOW);
422 }
423 let data: &[u8] = unsafe {
424 std::slice::from_raw_parts(data_base.add(off), data_len)
425 };
426 off += data_len;
427
428 f(&hdr, data)?;
429 last = hdr;
430 }
431
432 if !advance_cursor(unsafe { &mut (*args_ptr).key }, &last) {
433 break;
434 }
435 }
436
437 Ok(())
438}
439
440/// Try [`tree_search_v2`] first; if the driver can't serve v2,
441/// fall back to [`tree_search`] (v1) with the same filter and
442/// callback.
443///
444/// The fallback is triggered when v2's first ioctl returns one of:
445///
446/// - `ENOPROTOOPT`: our `btrfs-fuse` driver's signal that it
447/// doesn't support the indirected-buffer pattern v2 needs (the
448/// kernel's `FUSE_IOCTL_RETRY` path is restricted to ioctls
449/// issued with `FUSE_IOCTL_UNRESTRICTED`, which standard
450/// `ioctl(2)` callers never set). Picked specifically because
451/// no other path in the btrfs ioctl surface returns it.
452/// - `ENOTSUP` / `EOPNOTSUPP`: generic "op not supported on this
453/// endpoint", e.g. another FUSE-btrfs implementation.
454/// - `ENOTTY` / `ENOSYS`: very old kernels that pre-date v2
455/// (added in 3.18) returning "unknown ioctl" / "function not
456/// implemented".
457///
458/// **Safety against duplicate items.** v1 is only attempted when
459/// v2 errors *before* invoking the user callback. If v2 fails
460/// mid-walk (a transient condition we don't see in practice),
461/// the v2 error is propagated unchanged — re-running v1 from
462/// scratch would re-deliver items the caller already saw.
463///
464/// `buf_size` controls the v2 buffer size in bytes (default
465/// 64 KiB). It is ignored on the v1 fallback (v1 has a fixed
466/// 3992-byte buffer).
467///
468/// # Errors
469///
470/// Returns the v2 error if the callback was already invoked when
471/// it surfaced; the v1 error if v2 was unsupported and v1 also
472/// failed; or the callback's error if either path's callback
473/// returns one.
474#[allow(clippy::needless_pass_by_value)]
475pub fn tree_search_auto(
476 fd: BorrowedFd,
477 filter: SearchFilter,
478 buf_size: Option<usize>,
479 mut f: impl FnMut(&SearchHeader, &[u8]) -> nix::Result<()>,
480) -> nix::Result<()> {
481 let mut callback_invoked = false;
482 let v2_result =
483 tree_search_v2(fd, filter.clone(), buf_size, |hdr, data| {
484 callback_invoked = true;
485 f(hdr, data)
486 });
487
488 match v2_result {
489 Ok(()) => Ok(()),
490 Err(e) if !callback_invoked && is_v2_unsupported(e) => {
491 tree_search(fd, filter, f)
492 }
493 Err(e) => Err(e),
494 }
495}
496
497/// Errnos that signal the driver doesn't support v2 at all.
498/// Matches the cases enumerated in [`tree_search_auto`]'s docs.
499fn is_v2_unsupported(e: nix::errno::Errno) -> bool {
500 use nix::errno::Errno;
501 matches!(
502 e,
503 Errno::ENOPROTOOPT | Errno::ENOTSUP | Errno::ENOTTY | Errno::ENOSYS
504 )
505}
506
507fn fill_search_key(sk: &mut btrfs_ioctl_search_key, filter: &SearchFilter) {
508 sk.tree_id = filter.tree_id;
509 sk.min_objectid = filter.start.objectid;
510 sk.max_objectid = filter.end.objectid;
511 sk.min_type = filter.start.item_type;
512 sk.max_type = filter.end.item_type;
513 sk.min_offset = filter.start.offset;
514 sk.max_offset = filter.end.offset;
515 sk.min_transid = filter.min_transid;
516 sk.max_transid = filter.max_transid;
517}
518
519/// Advance the search cursor past `last` so the next batch begins from the
520/// item immediately after it in the 136-bit key space
521/// `(objectid << 72) | (type << 64) | offset`.
522///
523/// The kernel interprets `(min_objectid, min_type, min_offset)` as a compound
524/// tuple key, so all three fields must be updated together to point past the
525/// last returned item. Advancing only `min_offset` while leaving
526/// `min_objectid` at its original value would cause items from lower objectids
527/// that were already returned to satisfy the new minimum and be yielded again.
528///
529/// Returns `false` when the objectid also overflows, meaning the full key
530/// space has been exhausted.
531fn advance_cursor(
532 sk: &mut btrfs_ioctl_search_key,
533 last: &SearchHeader,
534) -> bool {
535 let (new_offset, offset_overflow) = last.offset.overflowing_add(1);
536 if !offset_overflow {
537 sk.min_objectid = last.objectid;
538 sk.min_type = last.item_type;
539 sk.min_offset = new_offset;
540 return true;
541 }
542
543 sk.min_offset = 0;
544 let (new_type, type_overflow) = last.item_type.overflowing_add(1);
545 if !type_overflow {
546 sk.min_objectid = last.objectid;
547 sk.min_type = new_type;
548 return true;
549 }
550
551 sk.min_type = 0;
552 let (new_oid, oid_overflow) = last.objectid.overflowing_add(1);
553 if oid_overflow {
554 return false;
555 }
556 sk.min_objectid = new_oid;
557 true
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 fn header(objectid: u64, item_type: u32, offset: u64) -> SearchHeader {
565 SearchHeader {
566 transid: 0,
567 objectid,
568 offset,
569 item_type,
570 len: 0,
571 }
572 }
573
574 fn zeroed_search_key() -> btrfs_ioctl_search_key {
575 unsafe { mem::zeroed() }
576 }
577
578 // --- SearchFilter constructors ---
579
580 #[test]
581 fn for_type_covers_all_objectids_and_offsets() {
582 let f = SearchFilter::for_type(5, 132);
583 assert_eq!(f.tree_id, 5);
584 assert_eq!(f.start.objectid, 0);
585 assert_eq!(f.end.objectid, u64::MAX);
586 assert_eq!(f.start.item_type, 132);
587 assert_eq!(f.end.item_type, 132);
588 assert_eq!(f.start.offset, 0);
589 assert_eq!(f.end.offset, u64::MAX);
590 assert_eq!(f.min_transid, 0);
591 assert_eq!(f.max_transid, u64::MAX);
592 }
593
594 #[test]
595 fn for_objectid_range_restricts_objectids() {
596 let f = SearchFilter::for_objectid_range(1, 84, 100, 200);
597 assert_eq!(f.tree_id, 1);
598 assert_eq!(f.start.objectid, 100);
599 assert_eq!(f.end.objectid, 200);
600 assert_eq!(f.start.item_type, 84);
601 assert_eq!(f.end.item_type, 84);
602 assert_eq!(f.start.offset, 0);
603 assert_eq!(f.end.offset, u64::MAX);
604 }
605
606 // --- fill_search_key ---
607
608 #[test]
609 fn fill_search_key_copies_all_fields() {
610 let filter = SearchFilter {
611 tree_id: 1,
612 start: Key {
613 objectid: 10,
614 item_type: 30,
615 offset: 50,
616 },
617 end: Key {
618 objectid: 20,
619 item_type: 40,
620 offset: 60,
621 },
622 min_transid: 70,
623 max_transid: 80,
624 };
625 let mut sk = zeroed_search_key();
626 fill_search_key(&mut sk, &filter);
627 assert_eq!(sk.tree_id, 1);
628 assert_eq!(sk.min_objectid, 10);
629 assert_eq!(sk.max_objectid, 20);
630 assert_eq!(sk.min_type, 30);
631 assert_eq!(sk.max_type, 40);
632 assert_eq!(sk.min_offset, 50);
633 assert_eq!(sk.max_offset, 60);
634 assert_eq!(sk.min_transid, 70);
635 assert_eq!(sk.max_transid, 80);
636 }
637
638 // --- advance_cursor: normal case ---
639
640 #[test]
641 fn advance_increments_offset() {
642 let mut sk = zeroed_search_key();
643 let last = header(256, 132, 100);
644 assert!(advance_cursor(&mut sk, &last));
645 assert_eq!(sk.min_objectid, 256);
646 assert_eq!(sk.min_type, 132);
647 assert_eq!(sk.min_offset, 101);
648 }
649
650 #[test]
651 fn advance_tracks_objectid_from_last_item() {
652 // This is the bug that was fixed: the cursor must track the last
653 // item's objectid, not leave min_objectid at its original value.
654 let mut sk = zeroed_search_key();
655 sk.min_objectid = 100; // original search started at 100
656 let last = header(300, 132, 50); // batch ended at objectid 300
657 assert!(advance_cursor(&mut sk, &last));
658 assert_eq!(sk.min_objectid, 300, "must track last item's objectid");
659 assert_eq!(sk.min_type, 132);
660 assert_eq!(sk.min_offset, 51);
661 }
662
663 #[test]
664 fn advance_tracks_type_from_last_item() {
665 let mut sk = zeroed_search_key();
666 let last = header(256, 180, 42);
667 assert!(advance_cursor(&mut sk, &last));
668 assert_eq!(sk.min_objectid, 256);
669 assert_eq!(sk.min_type, 180);
670 assert_eq!(sk.min_offset, 43);
671 }
672
673 // --- advance_cursor: offset overflow ---
674
675 #[test]
676 fn advance_offset_overflow_bumps_type() {
677 let mut sk = zeroed_search_key();
678 let last = header(256, 132, u64::MAX);
679 assert!(advance_cursor(&mut sk, &last));
680 assert_eq!(sk.min_objectid, 256);
681 assert_eq!(sk.min_type, 133);
682 assert_eq!(sk.min_offset, 0);
683 }
684
685 // --- advance_cursor: type overflow ---
686
687 #[test]
688 fn advance_type_overflow_bumps_objectid() {
689 let mut sk = zeroed_search_key();
690 let last = header(256, u32::MAX, u64::MAX);
691 assert!(advance_cursor(&mut sk, &last));
692 assert_eq!(sk.min_objectid, 257);
693 assert_eq!(sk.min_type, 0);
694 assert_eq!(sk.min_offset, 0);
695 }
696
697 // --- advance_cursor: full keyspace exhaustion ---
698
699 #[test]
700 fn advance_all_overflow_returns_false() {
701 let mut sk = zeroed_search_key();
702 let last = header(u64::MAX, u32::MAX, u64::MAX);
703 assert!(!advance_cursor(&mut sk, &last));
704 }
705
706 // --- advance_cursor: edge cases ---
707
708 #[test]
709 fn advance_zero_key() {
710 let mut sk = zeroed_search_key();
711 let last = header(0, 0, 0);
712 assert!(advance_cursor(&mut sk, &last));
713 assert_eq!(sk.min_objectid, 0);
714 assert_eq!(sk.min_type, 0);
715 assert_eq!(sk.min_offset, 1);
716 }
717
718 #[test]
719 fn advance_objectid_max_type_zero_offset_max() {
720 // offset overflows, type bumps to 1
721 let mut sk = zeroed_search_key();
722 let last = header(u64::MAX, 0, u64::MAX);
723 assert!(advance_cursor(&mut sk, &last));
724 assert_eq!(sk.min_objectid, u64::MAX);
725 assert_eq!(sk.min_type, 1);
726 assert_eq!(sk.min_offset, 0);
727 }
728
729 #[test]
730 fn advance_preserves_unrelated_search_key_fields() {
731 let mut sk = zeroed_search_key();
732 sk.max_objectid = 999;
733 sk.max_type = 888;
734 sk.max_offset = 777;
735 sk.max_transid = 666;
736 let last = header(10, 20, 30);
737 advance_cursor(&mut sk, &last);
738 assert_eq!(sk.max_objectid, 999);
739 assert_eq!(sk.max_type, 888);
740 assert_eq!(sk.max_offset, 777);
741 assert_eq!(sk.max_transid, 666);
742 }
743}