Skip to main content

acyclic_fs/kernel/
file_table.rs

1//! Canonical path-independent file records and authenticated file-table pages.
2
3use super::codec::{CanonicalDecodeError, DecodeLimits, Decoder, Encoder};
4use super::codec::{DecodedPageKind, DecodedPageShape};
5use super::file_table_mutation::FileTableFormat;
6use super::frontier;
7use super::persistent_batch;
8use super::types::{FileKind, digest_object};
9use crate::async_storage::AsyncObjectStore;
10use crate::cancellation::CancellationToken;
11use crate::foundation::FileId;
12use crate::performance::{OperationFailure, WorkBudget, WorkCounters, WorkError};
13use crate::storage::{ObjectId, ObjectKind, ObjectRead, ObjectReceipt, object_digest};
14use crate::storage::{ObjectStore, ObjectStoreError};
15use std::collections::HashSet;
16use std::fmt;
17use thiserror::Error;
18
19const DOMAIN: &[u8; 8] = b"ACYFSFIL";
20const VERSION: u16 = 2;
21const RECORD_DOMAIN: &[u8; 8] = b"ACYFSREC";
22const RECORD_VERSION: u16 = 1;
23
24/// Maximum regular-file bytes stored directly in a file-table leaf record.
25///
26/// The fixed bound keeps decoded records allocation-free and prevents tiny
27/// files from requiring separate extent, blob-index, and chunk objects.
28pub const MAXIMUM_INLINE_FILE_BYTES: usize = 64;
29
30/// Allocation-free canonical payload for one tiny regular file.
31#[derive(Clone, Copy, Eq, PartialEq)]
32pub struct InlineFileData {
33    length: u8,
34    bytes: [u8; MAXIMUM_INLINE_FILE_BYTES],
35}
36
37impl InlineFileData {
38    /// Copies one admitted tiny-file payload into its canonical fixed-capacity
39    /// representation.
40    ///
41    /// # Errors
42    ///
43    /// Rejects payloads larger than [`MAXIMUM_INLINE_FILE_BYTES`].
44    pub fn new(bytes: &[u8]) -> Result<Self, InlineFileDataError> {
45        let length = u8::try_from(bytes.len()).map_err(|_| InlineFileDataError::TooLarge)?;
46        if bytes.len() > MAXIMUM_INLINE_FILE_BYTES {
47            return Err(InlineFileDataError::TooLarge);
48        }
49        let mut stored = [0_u8; MAXIMUM_INLINE_FILE_BYTES];
50        #[allow(
51            clippy::indexing_slicing,
52            reason = "bytes.len() <= MAXIMUM_INLINE_FILE_BYTES == stored.len() was just checked above (the `if bytes.len() > MAXIMUM_INLINE_FILE_BYTES` early return)"
53        )]
54        stored[..bytes.len()].copy_from_slice(bytes);
55        Ok(Self {
56            length,
57            bytes: stored,
58        })
59    }
60
61    /// Returns the exact semantic file bytes without allocation.
62    #[must_use]
63    #[allow(
64        clippy::indexing_slicing,
65        reason = "self.length <= MAXIMUM_INLINE_FILE_BYTES == self.bytes.len() is the InlineFileData invariant maintained by every constructor: new() checks bytes.len() <= MAXIMUM_INLINE_FILE_BYTES before assigning length, and replace_range/truncate check logical_bytes against the same bound before assigning length"
66    )]
67    pub fn as_bytes(&self) -> &[u8] {
68        &self.bytes[..usize::from(self.length)]
69    }
70
71    pub(crate) fn replace_range(
72        mut self,
73        offset: usize,
74        replacement: &[u8],
75        logical_bytes: usize,
76    ) -> Result<Self, InlineFileDataError> {
77        let end = offset
78            .checked_add(replacement.len())
79            .ok_or(InlineFileDataError::TooLarge)?;
80        if end > logical_bytes || logical_bytes > MAXIMUM_INLINE_FILE_BYTES {
81            return Err(InlineFileDataError::TooLarge);
82        }
83        #[allow(
84            clippy::indexing_slicing,
85            reason = "end <= logical_bytes <= MAXIMUM_INLINE_FILE_BYTES == self.bytes.len() and offset <= end (end = offset + replacement.len() via checked_add) are both established by the check just above"
86        )]
87        self.bytes[offset..end].copy_from_slice(replacement);
88        #[allow(
89            clippy::indexing_slicing,
90            reason = "logical_bytes <= MAXIMUM_INLINE_FILE_BYTES == self.bytes.len() is established by the check just above"
91        )]
92        self.bytes[logical_bytes..].fill(0);
93        self.length = u8::try_from(logical_bytes).map_err(|_| InlineFileDataError::TooLarge)?;
94        Ok(self)
95    }
96
97    pub(crate) fn truncate(mut self, logical_bytes: usize) -> Result<Self, InlineFileDataError> {
98        if logical_bytes > self.as_bytes().len() {
99            return Err(InlineFileDataError::TooLarge);
100        }
101        #[allow(
102            clippy::indexing_slicing,
103            reason = "logical_bytes <= self.as_bytes().len() <= self.bytes.len() is established by the check just above (as_bytes() never exceeds the fixed-capacity buffer)"
104        )]
105        self.bytes[logical_bytes..].fill(0);
106        self.length = u8::try_from(logical_bytes).map_err(|_| InlineFileDataError::TooLarge)?;
107        Ok(self)
108    }
109}
110
111impl fmt::Debug for InlineFileData {
112    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
113        formatter
114            .debug_tuple("InlineFileData")
115            .field(&self.as_bytes())
116            .finish()
117    }
118}
119
120/// Tiny regular-file admission failures.
121#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
122pub enum InlineFileDataError {
123    /// Payload exceeds the canonical inline threshold.
124    #[error("inline regular-file payload exceeds its canonical bound")]
125    TooLarge,
126}
127
128/// Kind-specific immutable file payload.
129#[derive(Clone, Copy, Debug, Eq, PartialEq)]
130pub enum FilePayload {
131    /// Tiny regular-file bytes embedded in the authenticated file-table leaf.
132    InlineRegular(InlineFileData),
133    /// Regular file size and sparse extent-tree root.
134    Regular {
135        /// Exact logical bytes, including holes.
136        logical_bytes: u64,
137        /// Authenticated sparse extent tree.
138        extents: ObjectId,
139    },
140    /// Directory namespace-tree root.
141    Directory {
142        /// Authenticated directory tree.
143        entries: ObjectId,
144    },
145    /// Symbolic-link target stored as an authenticated blob.
146    SymbolicLink {
147        /// Exact target representation bytes.
148        target_bytes: u64,
149        /// Authenticated blob-index root.
150        target: ObjectId,
151    },
152    /// FIFO, socket, or mount-boundary entry with no payload.
153    Empty,
154    /// POSIX character or block device identity.
155    Device {
156        /// Device major number.
157        major: u32,
158        /// Device minor number.
159        minor: u32,
160    },
161    /// Opaque Windows reparse payload.
162    ReparsePoint {
163        /// Exact payload bytes.
164        payload_bytes: u64,
165        /// Authenticated blob-index root.
166        payload: ObjectId,
167    },
168}
169
170/// One path-independent immutable file record.
171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172pub struct FileRecord {
173    /// Stable identity reused by every hard link.
174    pub file_id: FileId,
175    /// Complete filesystem kind.
176    pub kind: FileKind,
177    /// Exact number of namespace bindings in this generation.
178    pub link_count: u64,
179    /// Canonical metadata object.
180    pub metadata: ObjectId,
181    /// Kind-specific content identity.
182    pub payload: FilePayload,
183}
184
185impl FileRecord {
186    pub(crate) fn validate(self) -> Result<(), FileTableError> {
187        if self.link_count == 0 || self.metadata.kind != ObjectKind::Metadata {
188            return Err(FileTableError::InvalidRecord);
189        }
190        let valid = match (self.kind, self.payload) {
191            (FileKind::Regular, FilePayload::Regular { extents, .. }) => {
192                extents.kind == ObjectKind::ExtentPage
193            }
194            (FileKind::Directory, FilePayload::Directory { entries }) => {
195                entries.kind == ObjectKind::TreePage
196            }
197            (FileKind::SymbolicLink, FilePayload::SymbolicLink { target, .. })
198            | (
199                FileKind::ReparsePoint,
200                FilePayload::ReparsePoint {
201                    payload: target, ..
202                },
203            ) => target.kind == ObjectKind::Blob,
204            (FileKind::Regular, FilePayload::InlineRegular(_))
205            | (FileKind::CharacterDevice | FileKind::BlockDevice, FilePayload::Device { .. })
206            | (FileKind::Fifo | FileKind::Socket | FileKind::MountBoundary, FilePayload::Empty) => {
207                true
208            }
209            _ => false,
210        };
211        if valid {
212            Ok(())
213        } else {
214            Err(FileTableError::InvalidRecord)
215        }
216    }
217}
218
219/// One lower-bound file-table child.
220#[derive(Clone, Copy, Debug, Eq, PartialEq)]
221pub struct FileTableChild {
222    /// Exact first file identity represented by the child.
223    pub first_file_id: FileId,
224    /// Authenticated child page.
225    pub page: ObjectId,
226}
227
228/// One immutable authenticated file-table B+tree page.
229#[derive(Clone, Debug, Eq, PartialEq)]
230pub enum FileTablePage {
231    /// Strictly ordered file records.
232    Leaf(Vec<FileRecord>),
233    /// Strictly ordered lower-bound child references.
234    Internal(Vec<FileTableChild>),
235}
236
237impl FileTablePage {
238    fn validate(&self, maximum_items: u32) -> Result<(), FileTableError> {
239        let count = match self {
240            Self::Leaf(records) => {
241                if records.windows(2).any(|pair| {
242                    let [first, second] = pair else {
243                        return false;
244                    };
245                    first.file_id >= second.file_id
246                }) {
247                    return Err(FileTableError::NotStrictlyOrdered);
248                }
249                for record in records {
250                    record.validate()?;
251                }
252                records.len()
253            }
254            Self::Internal(children) => {
255                if children.is_empty()
256                    || children.windows(2).any(|pair| {
257                        let [first, second] = pair else {
258                            return false;
259                        };
260                        first.first_file_id >= second.first_file_id
261                    })
262                    || children
263                        .iter()
264                        .any(|child| child.page.kind != ObjectKind::FileTablePage)
265                {
266                    return Err(FileTableError::InvalidInternalPage);
267                }
268                children.len()
269            }
270        };
271        if u32::try_from(count).unwrap_or(u32::MAX) > maximum_items {
272            return Err(FileTableError::TooManyItems);
273        }
274        Ok(())
275    }
276}
277
278/// Encodes one validated canonical file-table page.
279///
280/// # Errors
281///
282/// Rejects invalid records, ordering, bounds, or object classes.
283pub fn encode_file_table_page(
284    page: &FileTablePage,
285    maximum_items: u32,
286) -> Result<Vec<u8>, CanonicalDecodeError> {
287    match page {
288        FileTablePage::Leaf(records) => encode_file_table_leaf_records(records, maximum_items),
289        FileTablePage::Internal(children) => encode_file_table_internal_children(
290            children
291                .iter()
292                .map(|child| (child.first_file_id, child.page)),
293            maximum_items,
294        ),
295    }
296}
297
298pub(crate) fn encode_file_table_leaf_records(
299    records: &[FileRecord],
300    maximum_items: u32,
301) -> Result<Vec<u8>, CanonicalDecodeError> {
302    if u32::try_from(records.len()).unwrap_or(u32::MAX) > maximum_items {
303        return Err(invariant(FileTableError::TooManyItems));
304    }
305    if records.windows(2).any(|pair| {
306        let [first, second] = pair else {
307            return false;
308        };
309        first.file_id >= second.file_id
310    }) {
311        return Err(invariant(FileTableError::NotStrictlyOrdered));
312    }
313    let mut encoded_length = DOMAIN
314        .len()
315        .checked_add(2 + 1 + 4)
316        .ok_or(CanonicalDecodeError::LengthOverflow)?;
317    for record in records {
318        record.validate().map_err(invariant)?;
319        encoded_length = encoded_length
320            .checked_add(file_record_encoded_length(*record)?)
321            .ok_or(CanonicalDecodeError::LengthOverflow)?;
322    }
323    let mut encoder = Encoder::with_exact_capacity(DOMAIN, VERSION, encoded_length)?;
324    encoder.u8(1);
325    encoder.u32(count(records.len())?);
326    for record in records {
327        encode_record(&mut encoder, *record);
328    }
329    Ok(encoder.finish())
330}
331
332pub(crate) fn encode_file_table_internal_children<I>(
333    children: I,
334    maximum_items: u32,
335) -> Result<Vec<u8>, CanonicalDecodeError>
336where
337    I: Clone + ExactSizeIterator<Item = (FileId, ObjectId)>,
338{
339    let length = children.len();
340    if length == 0 {
341        return Err(invariant(FileTableError::InvalidInternalPage));
342    }
343    if u32::try_from(length).unwrap_or(u32::MAX) > maximum_items {
344        return Err(invariant(FileTableError::TooManyItems));
345    }
346    let mut prior = None;
347    for (file_id, page) in children.clone() {
348        if prior.is_some_and(|value| value >= file_id) || page.kind != ObjectKind::FileTablePage {
349            return Err(invariant(FileTableError::InvalidInternalPage));
350        }
351        prior = Some(file_id);
352    }
353    let encoded_length = length
354        .checked_mul(16 + 32)
355        .and_then(|items| DOMAIN.len().checked_add(2 + 1 + 4 + items))
356        .ok_or(CanonicalDecodeError::LengthOverflow)?;
357    let mut encoder = Encoder::with_exact_capacity(DOMAIN, VERSION, encoded_length)?;
358    encoder.u8(2);
359    encoder.u32(count(length)?);
360    for (file_id, page) in children {
361        encoder.fixed(&file_id.into_bytes());
362        encoder.fixed(page.digest.as_bytes());
363    }
364    Ok(encoder.finish())
365}
366
367pub(crate) fn file_table_page_decode_shape(
368    bytes: &[u8],
369    limits: DecodeLimits,
370) -> Result<DecodedPageShape, CanonicalDecodeError> {
371    let mut decoder = Decoder::new(bytes, DOMAIN, VERSION, limits.maximum_page_object_bytes())?;
372    let tag = decoder.u8()?;
373    let item_count = decoder.u32()?;
374    if item_count > limits.maximum_page_items {
375        return Err(CanonicalDecodeError::FieldTooLarge {
376            observed: item_count,
377            maximum: limits.maximum_page_items,
378        });
379    }
380    let kind = match tag {
381        1 => {
382            for _ in 0..item_count {
383                let _: [u8; 16] = decoder.fixed()?;
384                decoder.u8()?;
385                decoder.u64()?;
386                let _: [u8; 32] = decoder.fixed()?;
387                match decoder.u8()? {
388                    1 | 3 | 6 => {
389                        decoder.u64()?;
390                        let _: [u8; 32] = decoder.fixed()?;
391                    }
392                    2 => {
393                        let _: [u8; 32] = decoder.fixed()?;
394                    }
395                    4 => {}
396                    5 => {
397                        decoder.u32()?;
398                        decoder.u32()?;
399                    }
400                    7 => {
401                        let length = decoder.u8()?;
402                        if usize::from(length) > MAXIMUM_INLINE_FILE_BYTES {
403                            return Err(CanonicalDecodeError::FieldTooLarge {
404                                observed: u32::from(length),
405                                maximum: u32::try_from(MAXIMUM_INLINE_FILE_BYTES)
406                                    .map_err(|_| CanonicalDecodeError::LengthOverflow)?,
407                            });
408                        }
409                        decoder.take_exact(usize::from(length))?;
410                    }
411                    value => {
412                        return Err(CanonicalDecodeError::UnknownTag {
413                            field: "file_payload",
414                            tag: value,
415                        });
416                    }
417                }
418            }
419            DecodedPageKind::Leaf
420        }
421        2 => {
422            for _ in 0..item_count {
423                let _: [u8; 16] = decoder.fixed()?;
424                let _: [u8; 32] = decoder.fixed()?;
425            }
426            DecodedPageKind::Internal
427        }
428        value => {
429            return Err(CanonicalDecodeError::UnknownTag {
430                field: "file_table_page",
431                tag: value,
432            });
433        }
434    };
435    decoder.finish()?;
436    Ok(DecodedPageShape {
437        kind,
438        items: usize::try_from(item_count).map_err(|_| CanonicalDecodeError::LengthOverflow)?,
439        nested_bytes: 0,
440    })
441}
442
443pub(crate) fn file_record_encoded_length(
444    record: FileRecord,
445) -> Result<usize, CanonicalDecodeError> {
446    const PREFIX: usize = 16 + 1 + 8 + 32 + 1;
447    let payload = match record.payload {
448        FilePayload::InlineRegular(data) => 1_usize
449            .checked_add(data.as_bytes().len())
450            .ok_or(CanonicalDecodeError::LengthOverflow)?,
451        FilePayload::Regular { .. }
452        | FilePayload::SymbolicLink { .. }
453        | FilePayload::ReparsePoint { .. } => 8 + 32,
454        FilePayload::Directory { .. } => 32,
455        FilePayload::Empty => 0,
456        FilePayload::Device { .. } => 8,
457    };
458    PREFIX
459        .checked_add(payload)
460        .ok_or(CanonicalDecodeError::LengthOverflow)
461}
462
463fn encode_record(encoder: &mut Encoder, record: FileRecord) {
464    encoder.fixed(&record.file_id.into_bytes());
465    encoder.u8(record.kind.tag());
466    encoder.u64(record.link_count);
467    encoder.fixed(record.metadata.digest.as_bytes());
468    match record.payload {
469        FilePayload::InlineRegular(data) => {
470            encoder.u8(7);
471            encoder.u8(data.length);
472            encoder.fixed(data.as_bytes());
473        }
474        FilePayload::Regular {
475            logical_bytes,
476            extents,
477        } => {
478            encoder.u8(1);
479            encoder.u64(logical_bytes);
480            encoder.fixed(extents.digest.as_bytes());
481        }
482        FilePayload::Directory { entries } => {
483            encoder.u8(2);
484            encoder.fixed(entries.digest.as_bytes());
485        }
486        FilePayload::SymbolicLink {
487            target_bytes,
488            target,
489        } => {
490            encoder.u8(3);
491            encoder.u64(target_bytes);
492            encoder.fixed(target.digest.as_bytes());
493        }
494        FilePayload::Empty => encoder.u8(4),
495        FilePayload::Device { major, minor } => {
496            encoder.u8(5);
497            encoder.u32(major);
498            encoder.u32(minor);
499        }
500        FilePayload::ReparsePoint {
501            payload_bytes,
502            payload,
503        } => {
504            encoder.u8(6);
505            encoder.u64(payload_bytes);
506            encoder.fixed(payload.digest.as_bytes());
507        }
508    }
509}
510
511pub(crate) fn encode_file_record(record: FileRecord) -> Vec<u8> {
512    let mut encoder = Encoder::new(RECORD_DOMAIN, RECORD_VERSION);
513    encode_record(&mut encoder, record);
514    encoder.finish()
515}
516
517pub(crate) fn decode_file_record(bytes: &[u8]) -> Result<FileRecord, CanonicalDecodeError> {
518    let mut decoder = Decoder::new(
519        bytes,
520        RECORD_DOMAIN,
521        RECORD_VERSION,
522        u64::try_from(bytes.len()).unwrap_or(u64::MAX),
523    )?;
524    let record = decode_record(&mut decoder)?;
525    decoder.finish()?;
526    Ok(record)
527}
528
529/// Decodes one bounded canonical file-table page.
530///
531/// # Errors
532///
533/// Fails closed on malformed bytes, unknown versions/tags, invalid records,
534/// ordering, or bounds.
535pub fn decode_file_table_page(
536    bytes: &[u8],
537    limits: DecodeLimits,
538) -> Result<FileTablePage, CanonicalDecodeError> {
539    let mut decoder = Decoder::new(bytes, DOMAIN, VERSION, limits.maximum_page_object_bytes())?;
540    let tag = decoder.u8()?;
541    let item_count = decoder.u32()?;
542    if item_count > limits.maximum_page_items {
543        return Err(CanonicalDecodeError::FieldTooLarge {
544            observed: item_count,
545            maximum: limits.maximum_page_items,
546        });
547    }
548    let mut records = Vec::new();
549    let mut children = Vec::new();
550    match tag {
551        1 => {
552            records
553                .try_reserve_exact(capacity(item_count)?)
554                .map_err(|_| CanonicalDecodeError::AllocationFailed)?;
555            for _ in 0..item_count {
556                records.push(decode_record(&mut decoder)?);
557            }
558        }
559        2 => {
560            children
561                .try_reserve_exact(capacity(item_count)?)
562                .map_err(|_| CanonicalDecodeError::AllocationFailed)?;
563            for _ in 0..item_count {
564                children.push(FileTableChild {
565                    first_file_id: FileId::from_bytes(decoder.fixed()?),
566                    page: digest_object(ObjectKind::FileTablePage, decoder.fixed()?),
567                });
568            }
569        }
570        value => {
571            return Err(CanonicalDecodeError::UnknownTag {
572                field: "file_table_page",
573                tag: value,
574            });
575        }
576    }
577    decoder.finish()?;
578    let page = if tag == 1 {
579        FileTablePage::Leaf(records)
580    } else {
581        FileTablePage::Internal(children)
582    };
583    page.validate(limits.maximum_page_items)
584        .map_err(invariant)?;
585    Ok(page)
586}
587
588fn decode_record(decoder: &mut Decoder<'_>) -> Result<FileRecord, CanonicalDecodeError> {
589    let file_id = FileId::from_bytes(decoder.fixed()?);
590    let kind = FileKind::from_tag(decoder.u8()?).map_err(invariant_tree)?;
591    let link_count = decoder.u64()?;
592    let metadata = digest_object(ObjectKind::Metadata, decoder.fixed()?);
593    let payload = match decoder.u8()? {
594        1 => FilePayload::Regular {
595            logical_bytes: decoder.u64()?,
596            extents: digest_object(ObjectKind::ExtentPage, decoder.fixed()?),
597        },
598        2 => FilePayload::Directory {
599            entries: digest_object(ObjectKind::TreePage, decoder.fixed()?),
600        },
601        3 => FilePayload::SymbolicLink {
602            target_bytes: decoder.u64()?,
603            target: digest_object(ObjectKind::Blob, decoder.fixed()?),
604        },
605        4 => FilePayload::Empty,
606        5 => FilePayload::Device {
607            major: decoder.u32()?,
608            minor: decoder.u32()?,
609        },
610        6 => FilePayload::ReparsePoint {
611            payload_bytes: decoder.u64()?,
612            payload: digest_object(ObjectKind::Blob, decoder.fixed()?),
613        },
614        7 => {
615            let length = decoder.u8()?;
616            if usize::from(length) > MAXIMUM_INLINE_FILE_BYTES {
617                return Err(CanonicalDecodeError::FieldTooLarge {
618                    observed: u32::from(length),
619                    maximum: u32::try_from(MAXIMUM_INLINE_FILE_BYTES)
620                        .map_err(|_| CanonicalDecodeError::LengthOverflow)?,
621                });
622            }
623            let bytes = decoder.take_exact(usize::from(length))?;
624            FilePayload::InlineRegular(InlineFileData::new(bytes).map_err(invariant_inline)?)
625        }
626        tag => {
627            return Err(CanonicalDecodeError::UnknownTag {
628                field: "file_payload",
629                tag,
630            });
631        }
632    };
633    let record = FileRecord {
634        file_id,
635        kind,
636        link_count,
637        metadata,
638        payload,
639    };
640    record.validate().map_err(invariant)?;
641    Ok(record)
642}
643
644/// Computes one typed file-table page identity.
645///
646/// # Errors
647///
648/// Returns the same validation errors as [`encode_file_table_page`].
649pub fn file_table_page_id(
650    page: &FileTablePage,
651    maximum_items: u32,
652) -> Result<ObjectId, CanonicalDecodeError> {
653    let bytes = encode_file_table_page(page, maximum_items)?;
654    Ok(ObjectId {
655        kind: ObjectKind::FileTablePage,
656        digest: object_digest(ObjectKind::FileTablePage, &bytes),
657    })
658}
659
660/// Exact path-independent record lookup and measured authenticated work.
661#[derive(Clone, Debug, Eq, PartialEq)]
662pub struct FileRecordLookup {
663    /// Matching record, or authenticated absence.
664    pub record: Option<FileRecord>,
665    /// Exact page/backend work.
666    pub work: WorkCounters,
667}
668
669/// Original-order file records from one shared authenticated frontier batch.
670#[derive(Clone, Debug, Eq, PartialEq)]
671pub struct FileRecordBatchLookup {
672    /// One explicit present/absent result per requested stable file identity.
673    pub records: Vec<Option<FileRecord>>,
674    /// Exact shared traversal and backend work.
675    pub work: WorkCounters,
676}
677
678/// Looks up file records while reading every distinct file-table frontier once.
679///
680/// # Errors
681///
682/// Rejects empty/oversized batches and returns the same authenticated routing,
683/// storage, decode, allocation, and work failures as point lookup.
684pub fn lookup_file_records<S: crate::ImmediateObjectStore>(
685    store: &S,
686    root: ObjectId,
687    file_ids: &[FileId],
688    maximum_queries: u32,
689    limits: DecodeLimits,
690    budget: WorkBudget,
691) -> Result<FileRecordBatchLookup, FileRecordReadFailure> {
692    persistent_batch::lookup::<S, FileTableFormat>(
693        store,
694        root,
695        file_ids,
696        maximum_queries,
697        limits,
698        budget,
699    )
700    .map(to_record_batch)
701    .map_err(map_batch_failure)
702}
703
704/// Asynchronously executes the same batch as [`lookup_file_records`].
705///
706/// # Errors
707///
708/// Returns identical typed failures, including cooperative cancellation.
709pub async fn lookup_file_records_async<S: AsyncObjectStore>(
710    store: &S,
711    root: ObjectId,
712    file_ids: &[FileId],
713    maximum_queries: u32,
714    limits: DecodeLimits,
715    budget: WorkBudget,
716    cancellation: &CancellationToken,
717) -> Result<FileRecordBatchLookup, FileRecordReadFailure> {
718    persistent_batch::lookup_async::<S, FileTableFormat>(
719        store,
720        root,
721        file_ids,
722        maximum_queries,
723        limits,
724        budget,
725        cancellation,
726    )
727    .await
728    .map(to_record_batch)
729    .map_err(map_batch_failure)
730}
731
732fn to_record_batch(receipt: persistent_batch::Receipt<FileRecord>) -> FileRecordBatchLookup {
733    FileRecordBatchLookup {
734        records: receipt.values,
735        work: receipt.work,
736    }
737}
738
739fn map_batch_failure(failure: persistent_batch::Failure) -> FileRecordReadFailure {
740    OperationFailure::new(map_batch_error(failure.error), *failure.work)
741}
742
743fn map_batch_error(error: persistent_batch::Error) -> FileRecordReadError {
744    match error {
745        persistent_batch::Error::Cancelled => FileRecordReadError::Cancelled,
746        persistent_batch::Error::Empty => FileRecordReadError::EmptyBatch,
747        persistent_batch::Error::TooManyQueries => FileRecordReadError::TooManyQueries,
748        persistent_batch::Error::WrongRootKind => FileRecordReadError::WrongRootKind,
749        persistent_batch::Error::InvalidLimits => FileRecordReadError::InvalidHeightLimit,
750        persistent_batch::Error::HeightExceeded => FileRecordReadError::HeightExceeded,
751        persistent_batch::Error::CycleOrAlias => FileRecordReadError::Cycle,
752        persistent_batch::Error::ChildBoundsMismatch => FileRecordReadError::ChildBoundsMismatch,
753        persistent_batch::Error::InvalidRouting => FileRecordReadError::InvalidRouting,
754        persistent_batch::Error::AllocationFailed => FileRecordReadError::AllocationFailed,
755        persistent_batch::Error::Storage(error) => error.into(),
756        persistent_batch::Error::Decode(error) => error.into(),
757        persistent_batch::Error::Work(error) => error.into(),
758    }
759}
760
761/// Looks up one stable file identity through an authenticated file-table frontier.
762///
763/// # Errors
764///
765/// Fails on wrong object classes, routing forgery, cycles, excessive height,
766/// malformed canonical pages, backend failure, or work-budget exhaustion.
767pub fn lookup_file_record<S: ObjectStore>(
768    store: &S,
769    root: ObjectId,
770    file_id: FileId,
771    limits: DecodeLimits,
772    budget: WorkBudget,
773) -> Result<FileRecordLookup, FileRecordReadFailure> {
774    let mut machine = FileLookupMachine::new(root, file_id, limits, budget)?;
775    frontier::drive_sync(store, &mut machine)
776}
777
778/// Asynchronously looks up one file record through the same semantic machine
779/// as [`lookup_file_record`].
780///
781/// # Errors
782///
783/// Returns the same typed semantic, storage, decode, work, and cancellation
784/// failures as [`lookup_file_record`].
785pub async fn lookup_file_record_async<S: AsyncObjectStore>(
786    store: &S,
787    root: ObjectId,
788    file_id: FileId,
789    limits: DecodeLimits,
790    budget: WorkBudget,
791    cancellation: &CancellationToken,
792) -> Result<FileRecordLookup, FileRecordReadFailure> {
793    let mut machine = FileLookupMachine::new(root, file_id, limits, budget)?;
794    frontier::drive_async(store, &mut machine, cancellation).await
795}
796
797struct FileLookupMachine {
798    file_id: FileId,
799    limits: DecodeLimits,
800    budget: WorkBudget,
801    page: ObjectId,
802    lower: Option<FileId>,
803    upper: Option<FileId>,
804    visited: HashSet<ObjectId>,
805    work: WorkCounters,
806}
807
808impl FileLookupMachine {
809    fn new(
810        root: ObjectId,
811        file_id: FileId,
812        limits: DecodeLimits,
813        budget: WorkBudget,
814    ) -> Result<Self, FileRecordReadFailure> {
815        if root.kind != ObjectKind::FileTablePage {
816            return Err(file_read_failed(
817                FileRecordReadError::WrongRootKind,
818                WorkCounters::default(),
819            ));
820        }
821        if !limits.page_limits_valid(1) {
822            return Err(file_read_failed(
823                FileRecordReadError::InvalidHeightLimit,
824                WorkCounters::default(),
825            ));
826        }
827        Ok(Self {
828            file_id,
829            limits,
830            budget,
831            page: root,
832            lower: None,
833            upper: None,
834            visited: HashSet::new(),
835            work: WorkCounters::default(),
836        })
837    }
838
839    fn prepare_read(&mut self) -> Result<frontier::ReadRequest, FileRecordReadFailure> {
840        if self.visited.len() >= usize::from(self.limits.maximum_page_height) {
841            return Err(file_read_failed(
842                FileRecordReadError::HeightExceeded,
843                self.work,
844            ));
845        }
846        if !self.visited.insert(self.page) {
847            return Err(file_read_failed(FileRecordReadError::Cycle, self.work));
848        }
849        let prospective = self
850            .work
851            .checked_add(WorkCounters {
852                page_reads: 1,
853                ..WorkCounters::default()
854            })
855            .map_err(|error| file_read_failed(error.into(), self.work))?;
856        let remaining = prospective
857            .remaining(self.budget)
858            .map_err(|error| file_read_failed(error.into(), self.work))?;
859        Ok(frontier::ReadRequest {
860            page: self.page,
861            maximum_bytes: self.limits.maximum_page_object_bytes(),
862            remaining,
863            prospective,
864        })
865    }
866
867    fn accept(
868        &mut self,
869        prospective: WorkCounters,
870        receipt: &ObjectReceipt<ObjectRead>,
871    ) -> Result<Option<FileRecordLookup>, FileRecordReadFailure> {
872        self.work = prospective
873            .checked_add(receipt.work)
874            .map_err(|error| file_read_failed(error.into(), prospective))?;
875        self.work
876            .verify(self.budget)
877            .map_err(|error| file_read_failed(error.into(), self.work))?;
878        match decode_file_table_page(&receipt.value, self.limits)
879            .map_err(|error| file_read_failed(error.into(), self.work))?
880        {
881            FileTablePage::Leaf(records) => {
882                validate_record_bounds(&records, self.lower, self.upper)
883                    .map_err(|error| file_read_failed(error, self.work))?;
884                let record = records
885                    .binary_search_by_key(&self.file_id, |record| record.file_id)
886                    .ok()
887                    .and_then(|index| records.get(index).copied());
888                Ok(Some(FileRecordLookup {
889                    record,
890                    work: self.work,
891                }))
892            }
893            FileTablePage::Internal(children) => {
894                validate_child_bounds(&children, self.lower, self.upper)
895                    .map_err(|error| file_read_failed(error, self.work))?;
896                let partition =
897                    children.partition_point(|child| child.first_file_id <= self.file_id);
898                let selected = partition.saturating_sub(1);
899                let child = children.get(selected).copied().ok_or_else(|| {
900                    file_read_failed(FileRecordReadError::InvalidRouting, self.work)
901                })?;
902                self.lower = Some(child.first_file_id);
903                self.upper = children
904                    .get(selected + 1)
905                    .map(|next| next.first_file_id)
906                    .or(self.upper);
907                self.page = child.page;
908                Ok(None)
909            }
910        }
911    }
912}
913
914impl frontier::Machine for FileLookupMachine {
915    type Output = FileRecordLookup;
916    type Failure = FileRecordReadFailure;
917
918    fn complete(&mut self) -> Result<Option<Self::Output>, Self::Failure> {
919        Ok(None)
920    }
921
922    fn prepare_read(&mut self) -> Result<frontier::ReadRequest, Self::Failure> {
923        FileLookupMachine::prepare_read(self)
924    }
925
926    fn accept(
927        &mut self,
928        prospective: WorkCounters,
929        receipt: &ObjectReceipt<ObjectRead>,
930    ) -> Result<Option<Self::Output>, Self::Failure> {
931        FileLookupMachine::accept(self, prospective, receipt)
932    }
933
934    fn storage_failure(
935        &self,
936        prospective: WorkCounters,
937        failure: crate::storage::ObjectFailure,
938    ) -> Self::Failure {
939        match prospective.checked_add(*failure.work) {
940            Ok(spent) => file_read_failed(failure.error.into(), spent),
941            Err(error) => file_read_failed(error.into(), prospective),
942        }
943    }
944
945    fn cancelled(&self) -> Self::Failure {
946        file_read_failed(FileRecordReadError::Cancelled, self.work)
947    }
948}
949
950fn validate_record_bounds(
951    records: &[FileRecord],
952    lower: Option<FileId>,
953    upper: Option<FileId>,
954) -> Result<(), FileRecordReadError> {
955    if lower.is_some() && records.first().map(|record| record.file_id) != lower {
956        return Err(FileRecordReadError::ChildBoundsMismatch);
957    }
958    if let Some(upper) = upper
959        && records.last().is_some_and(|record| record.file_id >= upper)
960    {
961        return Err(FileRecordReadError::ChildBoundsMismatch);
962    }
963    Ok(())
964}
965
966fn validate_child_bounds(
967    children: &[FileTableChild],
968    lower: Option<FileId>,
969    upper: Option<FileId>,
970) -> Result<(), FileRecordReadError> {
971    if lower.is_some() && children.first().map(|child| child.first_file_id) != lower {
972        return Err(FileRecordReadError::ChildBoundsMismatch);
973    }
974    if let Some(upper) = upper
975        && children
976            .last()
977            .is_some_and(|child| child.first_file_id >= upper)
978    {
979        return Err(FileRecordReadError::ChildBoundsMismatch);
980    }
981    Ok(())
982}
983
984/// Sparse file-record lookup failure retaining exact spent work.
985pub type FileRecordReadFailure = OperationFailure<FileRecordReadError>;
986
987fn file_read_failed(error: FileRecordReadError, work: WorkCounters) -> FileRecordReadFailure {
988    OperationFailure::new(error, work)
989}
990
991/// Authenticated file-table frontier failures.
992#[derive(Debug, Error)]
993pub enum FileRecordReadError {
994    /// Cooperative cancellation occurred before the next storage boundary.
995    #[error("file-record lookup was cancelled")]
996    Cancelled,
997    /// Batch lookup requires at least one identity.
998    #[error("file-record lookup batch is empty")]
999    EmptyBatch,
1000    /// Batch lookup exceeds its explicit query limit.
1001    #[error("file-record lookup batch exceeds its admitted query bound")]
1002    TooManyQueries,
1003    /// Root is not a file-table page.
1004    #[error("file-record lookup root is not a file-table page")]
1005    WrongRootKind,
1006    /// Page-height limit must be non-zero.
1007    #[error("file-table height limit must be non-zero")]
1008    InvalidHeightLimit,
1009    /// Child graph references an ancestor.
1010    #[error("file-table graph contains a cycle")]
1011    Cycle,
1012    /// Traversal did not reach a leaf within the admitted height.
1013    #[error("file-table height exceeds its admitted bound")]
1014    HeightExceeded,
1015    /// Internal routing could not select a child.
1016    #[error("file-table routing invariant failed")]
1017    InvalidRouting,
1018    /// Parent lower/upper routing bounds disagree with the selected child.
1019    #[error("file-table child bounds do not match its page")]
1020    ChildBoundsMismatch,
1021    /// A bounded batch scratch allocation failed.
1022    #[error("file-record lookup allocation failed")]
1023    AllocationFailed,
1024    /// Immutable-object backend failed.
1025    #[error(transparent)]
1026    Storage(#[from] ObjectStoreError),
1027    /// Canonical page failed decoding or validation.
1028    #[error(transparent)]
1029    Decode(#[from] CanonicalDecodeError),
1030    /// Exact work exceeded or overflowed its budget.
1031    #[error(transparent)]
1032    Work(#[from] WorkError),
1033}
1034
1035/// File-table semantic failures.
1036#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
1037pub enum FileTableError {
1038    /// File identities must be strictly ordered and unique.
1039    #[error("file records are not strictly ordered")]
1040    NotStrictlyOrdered,
1041    /// Record kind, links, metadata, or payload is inconsistent.
1042    #[error("file record is inconsistent")]
1043    InvalidRecord,
1044    /// Internal page is empty, unordered, or points at the wrong object class.
1045    #[error("file-table internal page is invalid")]
1046    InvalidInternalPage,
1047    /// Page exceeds its admitted item bound.
1048    #[error("file-table page exceeds its item bound")]
1049    TooManyItems,
1050}
1051
1052fn count(value: usize) -> Result<u32, CanonicalDecodeError> {
1053    u32::try_from(value).map_err(|_| CanonicalDecodeError::LengthOverflow)
1054}
1055
1056fn capacity(value: u32) -> Result<usize, CanonicalDecodeError> {
1057    usize::try_from(value).map_err(|_| CanonicalDecodeError::LengthOverflow)
1058}
1059
1060fn invariant(error: FileTableError) -> CanonicalDecodeError {
1061    CanonicalDecodeError::Invariant(error.to_string())
1062}
1063
1064fn invariant_tree(error: super::TreePageError) -> CanonicalDecodeError {
1065    CanonicalDecodeError::Invariant(error.to_string())
1066}
1067
1068fn invariant_inline(error: InlineFileDataError) -> CanonicalDecodeError {
1069    CanonicalDecodeError::Invariant(error.to_string())
1070}
1071
1072#[cfg(test)]
1073#[path = "tests/file_table.rs"]
1074mod tests;