Skip to main content

acyclic_fs/kernel/
closure.rs

1//! Bounded whole-generation authentication before authority publication.
2
3use super::{
4    AttributeName, AttributePage, BlobNode, DecodeLimits, ExtentKind, ExtentPage, FilePayload,
5    FileRecord, FileTablePage, GenerationRoot, LogicalName, MetadataField, TreeEntry, TreePage,
6    decode_attribute_page, decode_blob_page, decode_extent_page, decode_file_metadata,
7    decode_file_table_page, decode_generation_root, decode_tree_page,
8};
9use crate::foundation::{FileId, GenerationId};
10use crate::model::FilesystemProfile;
11use crate::performance::{OperationFailure, WorkBudget, WorkCounters, WorkError};
12use crate::storage::{ObjectId, ObjectKind, ObjectStoreError};
13use crate::{CancellationToken, async_storage};
14use bytes::Bytes;
15use std::collections::{BTreeMap, HashMap, HashSet};
16use std::mem::size_of;
17use thiserror::Error;
18
19/// Hard closure-walk limits admitted before generation publication.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub struct ClosureLimits {
22    /// Canonical decoder/page limits.
23    pub decode: DecodeLimits,
24    /// Maximum distinct immutable objects read by the proof.
25    pub maximum_objects: u64,
26    /// Maximum path-independent file records.
27    pub maximum_files: u64,
28    /// Maximum cumulative canonical bytes read.
29    pub maximum_object_bytes: u64,
30    /// Exact namespace profile whose representable kinds are admitted.
31    pub profile: FilesystemProfile,
32    /// Whether symbolic-link records are admitted.
33    pub symbolic_links: bool,
34    /// Whether more than one namespace binding may reference a non-directory.
35    pub hard_links: bool,
36    /// Whether holes or allocated-zero extents are admitted.
37    pub sparse_files: bool,
38}
39
40/// Successful whole-generation proof.
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct GenerationProof {
43    /// Decoded immutable root.
44    pub root: GenerationRoot,
45    /// Content-addressed generation identity.
46    pub generation_id: GenerationId,
47    /// Distinct authenticated objects read.
48    pub object_count: u64,
49    /// Reachable path-independent file records.
50    pub file_count: u64,
51    /// Stable sorted identities in the complete authenticated closure.
52    pub objects: Vec<ObjectId>,
53    /// Exact proof work.
54    pub work: WorkCounters,
55}
56
57/// Generation-closure failure retaining all work spent before rejection.
58pub type GenerationProofFailure = OperationFailure<ClosureError>;
59
60/// Proves the complete immutable object closure and namespace/file-table consistency.
61///
62/// # Errors
63///
64/// Fails closed on missing/corrupt objects, malformed canonical bytes, routing
65/// forgery, graph cycles/aliases, unreachable records, kind/link mismatches,
66/// out-of-bounds content spans, or admitted resource limits.
67pub fn prove_generation_closure<S: crate::ImmediateObjectStore>(
68    store: &S,
69    generation_root: ObjectId,
70    limits: ClosureLimits,
71    budget: WorkBudget,
72) -> Result<GenerationProof, GenerationProofFailure> {
73    let cancellation = CancellationToken::new();
74    let mut context = ProofContext::new(store, limits, budget, &cancellation);
75    async_storage::poll_immediate(context.prove(generation_root))
76        .map_err(|error| OperationFailure::new(error, context.work))
77}
78
79/// Asynchronously proves a complete immutable generation closure.
80///
81/// Browser, remote, and native execution share this one canonical proof
82/// machine; only storage polling differs.
83///
84/// # Errors
85///
86/// Returns the same fail-closed proof errors as [`prove_generation_closure`],
87/// including asynchronous cancellation.
88pub async fn prove_generation_closure_async<S: crate::AsyncObjectStore>(
89    store: &S,
90    generation_root: ObjectId,
91    limits: ClosureLimits,
92    budget: WorkBudget,
93    cancellation: &CancellationToken,
94) -> Result<GenerationProof, GenerationProofFailure> {
95    let mut context = ProofContext::new(store, limits, budget, cancellation);
96    context
97        .prove(generation_root)
98        .await
99        .map_err(|error| OperationFailure::new(error, context.work))
100}
101
102#[cfg(feature = "s3-http")]
103pub(crate) async fn prove_blob_closure_async<S: crate::AsyncObjectStore>(
104    store: &S,
105    root: ObjectId,
106    expected_bytes: u64,
107    limits: ClosureLimits,
108    budget: WorkBudget,
109    cancellation: &CancellationToken,
110) -> Result<(Vec<ObjectId>, WorkCounters), GenerationProofFailure> {
111    let mut context = ProofContext::new(store, limits, budget, cancellation);
112    let logical_bytes = context
113        .prove_blob(root)
114        .await
115        .map_err(|error| OperationFailure::new(error, context.work))?;
116    if logical_bytes != expected_bytes {
117        return Err(OperationFailure::new(
118            ClosureError::BlobLengthMismatch,
119            context.work,
120        ));
121    }
122    let mut objects = context.objects.into_iter().collect::<Vec<_>>();
123    objects.sort_unstable();
124    Ok((objects, context.work))
125}
126
127struct ProofContext<'a, S> {
128    store: &'a S,
129    limits: ClosureLimits,
130    budget: WorkBudget,
131    work: WorkCounters,
132    objects: HashSet<ObjectId>,
133    blob_lengths: HashMap<ObjectId, u64>,
134    cancellation: &'a CancellationToken,
135}
136
137impl<'a, S: crate::AsyncObjectStore> ProofContext<'a, S> {
138    fn new(
139        store: &'a S,
140        limits: ClosureLimits,
141        budget: WorkBudget,
142        cancellation: &'a CancellationToken,
143    ) -> Self {
144        Self {
145            store,
146            limits,
147            budget,
148            work: WorkCounters::default(),
149            objects: HashSet::new(),
150            blob_lengths: HashMap::new(),
151            cancellation,
152        }
153    }
154
155    async fn prove(&mut self, generation_root: ObjectId) -> Result<GenerationProof, ClosureError> {
156        Self::require_kind(generation_root, ObjectKind::GenerationRoot)?;
157        let root_bytes = self.read_object(generation_root, false).await?;
158        let root = decode_generation_root(&root_bytes, self.limits.decode)?;
159        let generation_id = GenerationId::new(generation_root.digest);
160        let records = self.prove_file_table(root.file_table).await?;
161        let file_count = u64::try_from(records.len()).unwrap_or(u64::MAX);
162        if file_count > self.limits.maximum_files {
163            return Err(ClosureError::TooManyFiles {
164                observed: file_count,
165                maximum: self.limits.maximum_files,
166            });
167        }
168        self.prove_records_and_namespace(root.root_file_id, &records)
169            .await?;
170        let mut objects: Vec<ObjectId> = self.objects.iter().copied().collect();
171        objects.sort_unstable_by(|left, right| {
172            left.kind
173                .canonical_tag()
174                .cmp(&right.kind.canonical_tag())
175                .then_with(|| left.digest.as_bytes().cmp(right.digest.as_bytes()))
176        });
177        let object_bytes = u64::try_from(objects.capacity())
178            .unwrap_or(u64::MAX)
179            .saturating_mul(u64::try_from(size_of::<ObjectId>()).unwrap_or(u64::MAX));
180        self.work = self
181            .work
182            .checked_add(WorkCounters {
183                items_examined: u64::try_from(objects.len()).unwrap_or(u64::MAX),
184                bytes_copied: u64::try_from(objects.len())
185                    .unwrap_or(u64::MAX)
186                    .saturating_mul(u64::try_from(size_of::<ObjectId>()).unwrap_or(u64::MAX)),
187                allocation_operations: u64::from(!objects.is_empty()),
188                peak_allocation_bytes: object_bytes,
189                ..WorkCounters::default()
190            })
191            .map_err(ClosureError::Work)?;
192        self.work.verify(self.budget).map_err(ClosureError::Work)?;
193        Ok(GenerationProof {
194            root,
195            generation_id,
196            object_count: u64::try_from(self.objects.len()).unwrap_or(u64::MAX),
197            file_count,
198            objects,
199            work: self.work,
200        })
201    }
202
203    async fn read_object(&mut self, object: ObjectId, page: bool) -> Result<Bytes, ClosureError> {
204        self.objects.insert(object);
205        let count = u64::try_from(self.objects.len()).unwrap_or(u64::MAX);
206        if count > self.limits.maximum_objects {
207            return Err(ClosureError::TooManyObjects {
208                observed: count,
209                maximum: self.limits.maximum_objects,
210            });
211        }
212        let semantic = WorkCounters {
213            page_reads: u64::from(page),
214            ..WorkCounters::default()
215        };
216        let prospective = self.work.checked_add(semantic)?;
217        let remaining = prospective.remaining(self.budget)?;
218        let receipt = crate::AsyncObjectStore::read(
219            self.store,
220            object,
221            self.limits.decode.maximum_object_bytes,
222            remaining,
223            self.cancellation,
224        )
225        .await
226        .map_err(|failure| match prospective.checked_add(*failure.work) {
227            Ok(combined) => {
228                self.work = combined;
229                ClosureError::Storage(failure.error)
230            }
231            Err(error) => {
232                self.work = prospective;
233                ClosureError::Work(error)
234            }
235        })?;
236        self.work = prospective.checked_add(receipt.work)?;
237        self.work.verify(self.budget)?;
238        if self.work.object_bytes_read > self.limits.maximum_object_bytes {
239            return Err(ClosureError::ClosureBytesExceeded {
240                observed: self.work.object_bytes_read,
241                maximum: self.limits.maximum_object_bytes,
242            });
243        }
244        Ok(receipt.value.bytes)
245    }
246
247    async fn prove_file_table(
248        &mut self,
249        root: ObjectId,
250    ) -> Result<BTreeMap<FileId, FileRecord>, ClosureError> {
251        Self::require_kind(root, ObjectKind::FileTablePage)?;
252        let mut records = BTreeMap::new();
253        let mut pending = vec![(root, None, None)];
254        let mut pages = HashSet::new();
255        while let Some((page_id, lower, upper)) = pending.pop() {
256            if !pages.insert(page_id) {
257                return Err(ClosureError::RoutingAlias);
258            }
259            let bytes = self.read_object(page_id, true).await?;
260            match decode_file_table_page(&bytes, self.limits.decode)? {
261                FileTablePage::Leaf(entries) => {
262                    validate_file_bounds(&entries, lower, upper)?;
263                    for record in entries {
264                        if records.insert(record.file_id, record).is_some() {
265                            return Err(ClosureError::DuplicateFileRecord(record.file_id));
266                        }
267                    }
268                }
269                FileTablePage::Internal(children) => {
270                    validate_file_child_bounds(&children, lower, upper)?;
271                    for (index, child) in children.iter().enumerate().rev() {
272                        let child = *child;
273                        let child_upper = children
274                            .get(index + 1)
275                            .map(|next| next.first_file_id)
276                            .or(upper);
277                        pending.push((child.page, Some(child.first_file_id), child_upper));
278                    }
279                }
280            }
281        }
282        Ok(records)
283    }
284
285    async fn prove_records_and_namespace(
286        &mut self,
287        root_file_id: FileId,
288        records: &BTreeMap<FileId, FileRecord>,
289    ) -> Result<(), ClosureError> {
290        let root = records
291            .get(&root_file_id)
292            .ok_or(ClosureError::MissingRootFile)?;
293        if !matches!(root.payload, FilePayload::Directory { .. }) {
294            return Err(ClosureError::RootIsNotDirectory);
295        }
296        let mut directory_entries = BTreeMap::new();
297        for record in records.values() {
298            if !record.kind.is_supported_by_profile(self.limits.profile)
299                || (record.kind == super::FileKind::SymbolicLink && !self.limits.symbolic_links)
300                || (record.link_count > 1 && !self.limits.hard_links)
301            {
302                return Err(ClosureError::UnsupportedVolumeSemantics);
303            }
304            self.prove_metadata(record.metadata).await?;
305            match record.payload {
306                FilePayload::Regular {
307                    logical_bytes,
308                    extents,
309                } => {
310                    self.prove_extent_tree(extents, logical_bytes).await?;
311                }
312                FilePayload::Directory { entries } => {
313                    directory_entries.insert(record.file_id, self.prove_tree(entries).await?);
314                }
315                FilePayload::SymbolicLink {
316                    target_bytes,
317                    target,
318                }
319                | FilePayload::ReparsePoint {
320                    payload_bytes: target_bytes,
321                    payload: target,
322                } => {
323                    if self.prove_blob(target).await? != target_bytes {
324                        return Err(ClosureError::BlobLengthMismatch);
325                    }
326                }
327                FilePayload::InlineRegular(_) | FilePayload::Empty | FilePayload::Device { .. } => {
328                }
329            }
330        }
331        Self::validate_namespace(root_file_id, records, &directory_entries)
332    }
333
334    async fn prove_metadata(&mut self, metadata_id: ObjectId) -> Result<(), ClosureError> {
335        Self::require_kind(metadata_id, ObjectKind::Metadata)?;
336        // Metadata identities are immutable and authenticated. Large trees commonly
337        // share one canonical metadata object across many records, so proving an
338        // identity already present in this closure must not issue another backend
339        // read or decode. Metadata objects enter `objects` only through this method.
340        if self.objects.contains(&metadata_id) {
341            return Ok(());
342        }
343        let bytes = self.read_object(metadata_id, false).await?;
344        let metadata = decode_file_metadata(&bytes, self.limits.decode)?;
345        if let MetadataField::Value(attributes) = metadata.named_attributes {
346            self.prove_attributes(attributes).await?;
347        }
348        for blob in [metadata.acl, metadata.security_descriptor] {
349            if let MetadataField::Value(blob) = blob {
350                self.prove_blob(blob).await?;
351            }
352        }
353        Ok(())
354    }
355
356    async fn prove_tree(&mut self, root: ObjectId) -> Result<Vec<TreeEntry>, ClosureError> {
357        Self::require_kind(root, ObjectKind::TreePage)?;
358        let mut entries = Vec::new();
359        let mut pending = vec![(root, None, None)];
360        let mut pages = HashSet::new();
361        while let Some((page_id, lower, upper)) = pending.pop() {
362            if !pages.insert(page_id) {
363                return Err(ClosureError::RoutingAlias);
364            }
365            let bytes = self.read_object(page_id, true).await?;
366            match decode_tree_page(&bytes, self.limits.decode)? {
367                TreePage::Leaf(values) => {
368                    validate_tree_bounds(&values, lower.as_ref(), upper.as_ref())?;
369                    entries.extend(values);
370                }
371                TreePage::Internal(children) => {
372                    validate_tree_child_bounds(&children, lower.as_ref(), upper.as_ref())?;
373                    for (index, child) in children.iter().enumerate().rev() {
374                        let child = child.clone();
375                        let child_upper = children
376                            .get(index + 1)
377                            .map(|next| next.first_name.clone())
378                            .or_else(|| upper.clone());
379                        pending.push((child.page, Some(child.first_name), child_upper));
380                    }
381                }
382            }
383        }
384        Ok(entries)
385    }
386
387    async fn prove_extent_tree(&mut self, root: ObjectId, size: u64) -> Result<(), ClosureError> {
388        Self::require_kind(root, ObjectKind::ExtentPage)?;
389        let mut pending = vec![(root, 0_u64, size)];
390        let mut pages = HashSet::new();
391        while let Some((page_id, first, end)) = pending.pop() {
392            if !pages.insert(page_id) {
393                return Err(ClosureError::RoutingAlias);
394            }
395            let bytes = self.read_object(page_id, true).await?;
396            match decode_extent_page(&bytes, self.limits.decode)? {
397                ExtentPage::Leaf(extents) => {
398                    validate_extent_bounds(&extents, first, end)?;
399                    for extent in extents {
400                        if !self.limits.sparse_files
401                            && !matches!(extent.kind, ExtentKind::Content { .. })
402                        {
403                            return Err(ClosureError::UnsupportedVolumeSemantics);
404                        }
405                        if let ExtentKind::Content {
406                            object,
407                            object_offset,
408                        } = extent.kind
409                        {
410                            let content_end = object_offset
411                                .checked_add(extent.length)
412                                .ok_or(ClosureError::RangeOverflow)?;
413                            if content_end > self.prove_blob(object).await? {
414                                return Err(ClosureError::BlobSpanOutsideObject);
415                            }
416                        }
417                    }
418                }
419                ExtentPage::Internal(children) => {
420                    if children.first().map(|child| child.first_offset) != Some(first)
421                        || children.last().map(|child| child.end_offset) != Some(end)
422                    {
423                        return Err(ClosureError::RoutingBoundsMismatch);
424                    }
425                    for child in children.into_iter().rev() {
426                        pending.push((child.page, child.first_offset, child.end_offset));
427                    }
428                }
429            }
430        }
431        Ok(())
432    }
433
434    async fn prove_blob(&mut self, root: ObjectId) -> Result<u64, ClosureError> {
435        Self::require_kind(root, ObjectKind::Blob)?;
436        if let Some(length) = self.blob_lengths.get(&root) {
437            return Ok(*length);
438        }
439        let mut pending = vec![(root, 0_u64, None)];
440        let mut pages = HashSet::new();
441        let mut root_length = None;
442        while let Some((page_id, first, end)) = pending.pop() {
443            if !pages.insert(page_id) {
444                return Err(ClosureError::RoutingAlias);
445            }
446            let bytes = self.read_object(page_id, true).await?;
447            let page = decode_blob_page(&bytes, self.limits.decode)?;
448            if page.first_offset != first || end.is_some_and(|value| page.end_offset != value) {
449                return Err(ClosureError::RoutingBoundsMismatch);
450            }
451            if root_length.is_none() {
452                root_length = Some(page.end_offset);
453            }
454            match page.node {
455                BlobNode::Internal(children) => {
456                    for child in children.into_iter().rev() {
457                        pending.push((child.page, child.first_offset, Some(child.end_offset)));
458                    }
459                }
460                BlobNode::Leaf(chunks) => {
461                    for chunk in chunks {
462                        let expected = chunk.end_offset - chunk.first_offset;
463                        Self::require_kind(chunk.chunk, ObjectKind::BlobChunk)?;
464                        let value = self.read_object(chunk.chunk, false).await?;
465                        if u64::try_from(value.len()).unwrap_or(u64::MAX) != expected {
466                            return Err(ClosureError::BlobLengthMismatch);
467                        }
468                    }
469                }
470            }
471        }
472        let length = root_length.ok_or(ClosureError::RoutingBoundsMismatch)?;
473        self.blob_lengths.insert(root, length);
474        Ok(length)
475    }
476
477    async fn prove_attributes(&mut self, root: ObjectId) -> Result<(), ClosureError> {
478        Self::require_kind(root, ObjectKind::AttributePage)?;
479        let mut pending = vec![(root, None, None)];
480        let mut pages = HashSet::new();
481        while let Some((page_id, lower, upper)) = pending.pop() {
482            if !pages.insert(page_id) {
483                return Err(ClosureError::RoutingAlias);
484            }
485            let bytes = self.read_object(page_id, true).await?;
486            match decode_attribute_page(&bytes, self.limits.decode)? {
487                AttributePage::Leaf(entries) => {
488                    validate_attribute_bounds(&entries, lower.as_ref(), upper.as_ref())?;
489                    for entry in entries {
490                        if self.prove_blob(entry.value).await? != entry.value_bytes {
491                            return Err(ClosureError::BlobLengthMismatch);
492                        }
493                    }
494                }
495                AttributePage::Internal(children) => {
496                    validate_attribute_child_bounds(&children, lower.as_ref(), upper.as_ref())?;
497                    for (index, child) in children.iter().enumerate().rev() {
498                        let child = child.clone();
499                        let child_upper = children
500                            .get(index + 1)
501                            .map(|next| next.first_name.clone())
502                            .or_else(|| upper.clone());
503                        pending.push((child.page, Some(child.first_name), child_upper));
504                    }
505                }
506            }
507        }
508        Ok(())
509    }
510
511    fn validate_namespace(
512        root: FileId,
513        records: &BTreeMap<FileId, FileRecord>,
514        directories: &BTreeMap<FileId, Vec<TreeEntry>>,
515    ) -> Result<(), ClosureError> {
516        let mut links: BTreeMap<FileId, u64> = BTreeMap::new();
517        links.insert(root, 1);
518        for entries in directories.values() {
519            for entry in entries {
520                let record = records
521                    .get(&entry.file_id)
522                    .ok_or(ClosureError::MissingFileRecord(entry.file_id))?;
523                if record.kind != entry.kind {
524                    return Err(ClosureError::NamespaceKindMismatch(entry.file_id));
525                }
526                let count = links.entry(entry.file_id).or_default();
527                *count = count.checked_add(1).ok_or(ClosureError::RangeOverflow)?;
528            }
529        }
530        for record in records.values() {
531            if links.get(&record.file_id).copied().unwrap_or(0) != record.link_count {
532                return Err(ClosureError::LinkCountMismatch(record.file_id));
533            }
534            if matches!(record.payload, FilePayload::Directory { .. }) && record.link_count != 1 {
535                return Err(ClosureError::DirectoryHardLink(record.file_id));
536            }
537        }
538        let mut reachable = HashSet::new();
539        let mut visited_directories = HashSet::new();
540        let mut pending = vec![root];
541        while let Some(file_id) = pending.pop() {
542            if !visited_directories.insert(file_id) {
543                return Err(ClosureError::DirectoryHardLink(file_id));
544            }
545            reachable.insert(file_id);
546            let entries = directories
547                .get(&file_id)
548                .ok_or(ClosureError::MissingDirectoryTree(file_id))?;
549            for entry in entries {
550                reachable.insert(entry.file_id);
551                if directories.contains_key(&entry.file_id) {
552                    pending.push(entry.file_id);
553                }
554            }
555        }
556        if reachable.len() != records.len() {
557            return Err(ClosureError::UnreachableFileRecords);
558        }
559        Ok(())
560    }
561
562    fn require_kind(object: ObjectId, expected: ObjectKind) -> Result<(), ClosureError> {
563        if object.kind == expected {
564            Ok(())
565        } else {
566            Err(ClosureError::WrongObjectKind)
567        }
568    }
569}
570
571fn validate_file_bounds(
572    records: &[FileRecord],
573    lower: Option<FileId>,
574    upper: Option<FileId>,
575) -> Result<(), ClosureError> {
576    if lower.is_some_and(|value| records.first().map(|record| record.file_id) != Some(value))
577        || upper.is_some_and(|value| records.last().is_some_and(|record| record.file_id >= value))
578    {
579        return Err(ClosureError::RoutingBoundsMismatch);
580    }
581    Ok(())
582}
583
584fn validate_file_child_bounds(
585    children: &[super::FileTableChild],
586    lower: Option<FileId>,
587    upper: Option<FileId>,
588) -> Result<(), ClosureError> {
589    if lower.is_some_and(|value| children.first().map(|child| child.first_file_id) != Some(value))
590        || upper.is_some_and(|value| {
591            children
592                .last()
593                .is_some_and(|child| child.first_file_id >= value)
594        })
595    {
596        return Err(ClosureError::RoutingBoundsMismatch);
597    }
598    Ok(())
599}
600
601fn validate_tree_bounds(
602    entries: &[TreeEntry],
603    lower: Option<&LogicalName>,
604    upper: Option<&LogicalName>,
605) -> Result<(), ClosureError> {
606    if lower.is_some_and(|value| entries.first().map(|entry| &entry.name) != Some(value))
607        || upper.is_some_and(|value| entries.last().is_some_and(|entry| entry.name >= *value))
608    {
609        return Err(ClosureError::RoutingBoundsMismatch);
610    }
611    Ok(())
612}
613
614fn validate_tree_child_bounds(
615    children: &[super::TreeChild],
616    lower: Option<&LogicalName>,
617    upper: Option<&LogicalName>,
618) -> Result<(), ClosureError> {
619    if lower.is_some_and(|value| children.first().map(|child| &child.first_name) != Some(value))
620        || upper.is_some_and(|value| {
621            children
622                .last()
623                .is_some_and(|child| child.first_name >= *value)
624        })
625    {
626        return Err(ClosureError::RoutingBoundsMismatch);
627    }
628    Ok(())
629}
630
631fn validate_extent_bounds(
632    extents: &[super::Extent],
633    first: u64,
634    end: u64,
635) -> Result<(), ClosureError> {
636    if first == end && extents.is_empty() {
637        return Ok(());
638    }
639    if extents.first().map(|extent| extent.offset) != Some(first)
640        || extents
641            .last()
642            .and_then(|extent| extent.offset.checked_add(extent.length))
643            != Some(end)
644    {
645        return Err(ClosureError::RoutingBoundsMismatch);
646    }
647    Ok(())
648}
649
650fn validate_attribute_bounds(
651    entries: &[super::AttributeEntry],
652    lower: Option<&AttributeName>,
653    upper: Option<&AttributeName>,
654) -> Result<(), ClosureError> {
655    if lower.is_some_and(|value| entries.first().map(|entry| &entry.name) != Some(value))
656        || upper.is_some_and(|value| entries.last().is_some_and(|entry| entry.name >= *value))
657    {
658        return Err(ClosureError::RoutingBoundsMismatch);
659    }
660    Ok(())
661}
662
663fn validate_attribute_child_bounds(
664    children: &[super::AttributeChild],
665    lower: Option<&AttributeName>,
666    upper: Option<&AttributeName>,
667) -> Result<(), ClosureError> {
668    if lower.is_some_and(|value| children.first().map(|child| &child.first_name) != Some(value))
669        || upper.is_some_and(|value| {
670            children
671                .last()
672                .is_some_and(|child| child.first_name >= *value)
673        })
674    {
675        return Err(ClosureError::RoutingBoundsMismatch);
676    }
677    Ok(())
678}
679
680/// Whole-generation proof failures.
681#[derive(Debug, Error)]
682pub enum ClosureError {
683    /// Typed object reference has the wrong class.
684    #[error("generation closure object has the wrong kind")]
685    WrongObjectKind,
686    /// Same object was encountered twice where the graph requires a tree.
687    #[error("routing graph contains a cycle or alias")]
688    RoutingAlias,
689    /// Parent lower/upper bounds do not match authenticated child content.
690    #[error("routing bounds do not match authenticated child content")]
691    RoutingBoundsMismatch,
692    /// Closure exceeds its object-count bound.
693    #[error("generation closure has {observed} objects; maximum is {maximum}")]
694    TooManyObjects {
695        /// Distinct objects encountered.
696        observed: u64,
697        /// Admitted maximum.
698        maximum: u64,
699    },
700    /// Closure exceeds its file-count bound.
701    #[error("generation closure has {observed} files; maximum is {maximum}")]
702    TooManyFiles {
703        /// File records encountered.
704        observed: u64,
705        /// Admitted maximum.
706        maximum: u64,
707    },
708    /// Closure exceeds its cumulative canonical-byte bound.
709    #[error("generation closure read {observed} bytes; maximum is {maximum}")]
710    ClosureBytesExceeded {
711        /// Cumulative canonical bytes read.
712        observed: u64,
713        /// Admitted maximum.
714        maximum: u64,
715    },
716    /// File table contains the same stable identity twice.
717    #[error("duplicate file record {0:?}")]
718    DuplicateFileRecord(FileId),
719    /// Root file record is absent.
720    #[error("root file record is missing")]
721    MissingRootFile,
722    /// Root file is not a directory.
723    #[error("root file record is not a directory")]
724    RootIsNotDirectory,
725    /// A record or extent exceeds the volume's declared semantic capabilities.
726    #[error("generation closure uses semantics unsupported by its volume configuration")]
727    UnsupportedVolumeSemantics,
728    /// Namespace references an absent file record.
729    #[error("namespace references missing file record {0:?}")]
730    MissingFileRecord(FileId),
731    /// Namespace entry kind differs from its file record.
732    #[error("namespace kind differs from file record {0:?}")]
733    NamespaceKindMismatch(FileId),
734    /// Recorded hard-link count differs from exact namespace references.
735    #[error("file link count is incorrect for {0:?}")]
736    LinkCountMismatch(FileId),
737    /// Directories cannot have hard links.
738    #[error("directory has multiple namespace links {0:?}")]
739    DirectoryHardLink(FileId),
740    /// Directory record has no proven namespace tree.
741    #[error("directory record has no namespace tree {0:?}")]
742    MissingDirectoryTree(FileId),
743    /// File table contains records unreachable from the root.
744    #[error("file table contains unreachable records")]
745    UnreachableFileRecords,
746    /// Blob logical length differs from its declared use.
747    #[error("blob logical length does not match its declaration")]
748    BlobLengthMismatch,
749    /// Extent references bytes outside its blob.
750    #[error("extent content span exceeds its blob")]
751    BlobSpanOutsideObject,
752    /// Offset/count arithmetic overflowed.
753    #[error("generation closure range arithmetic overflowed")]
754    RangeOverflow,
755    /// Immutable object backend failed.
756    #[error(transparent)]
757    Storage(#[from] ObjectStoreError),
758    /// Canonical object failed decoding or semantic validation.
759    #[error(transparent)]
760    Decode(#[from] super::CanonicalDecodeError),
761    /// Exact work exceeded or overflowed its budget.
762    #[error(transparent)]
763    Work(#[from] WorkError),
764}
765
766#[cfg(all(test, feature = "memory"))]
767#[path = "tests/closure.rs"]
768mod tests;