Skip to main content

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}