use super::{
AttributeName, AttributePage, BlobNode, DecodeLimits, ExtentKind, ExtentPage, FilePayload,
FileRecord, FileTablePage, GenerationRoot, LogicalName, MetadataField, TreeEntry, TreePage,
decode_attribute_page, decode_blob_page, decode_extent_page, decode_file_metadata,
decode_file_table_page, decode_generation_root, decode_tree_page,
};
use crate::foundation::{FileId, GenerationId};
use crate::model::FilesystemProfile;
use crate::performance::{OperationFailure, WorkBudget, WorkCounters, WorkError};
use crate::storage::{ObjectId, ObjectKind, ObjectStoreError};
use crate::{CancellationToken, async_storage};
use bytes::Bytes;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::mem::size_of;
use thiserror::Error;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ClosureLimits {
pub decode: DecodeLimits,
pub maximum_objects: u64,
pub maximum_files: u64,
pub maximum_object_bytes: u64,
pub profile: FilesystemProfile,
pub symbolic_links: bool,
pub hard_links: bool,
pub sparse_files: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GenerationProof {
pub root: GenerationRoot,
pub generation_id: GenerationId,
pub object_count: u64,
pub file_count: u64,
pub objects: Vec<ObjectId>,
pub work: WorkCounters,
}
pub type GenerationProofFailure = OperationFailure<ClosureError>;
pub fn prove_generation_closure<S: crate::ImmediateObjectStore>(
store: &S,
generation_root: ObjectId,
limits: ClosureLimits,
budget: WorkBudget,
) -> Result<GenerationProof, GenerationProofFailure> {
let cancellation = CancellationToken::new();
let mut context = ProofContext::new(store, limits, budget, &cancellation);
async_storage::poll_immediate(context.prove(generation_root))
.map_err(|error| OperationFailure::new(error, context.work))
}
pub async fn prove_generation_closure_async<S: crate::AsyncObjectStore>(
store: &S,
generation_root: ObjectId,
limits: ClosureLimits,
budget: WorkBudget,
cancellation: &CancellationToken,
) -> Result<GenerationProof, GenerationProofFailure> {
let mut context = ProofContext::new(store, limits, budget, cancellation);
context
.prove(generation_root)
.await
.map_err(|error| OperationFailure::new(error, context.work))
}
#[cfg(feature = "s3-http")]
pub(crate) async fn prove_blob_closure_async<S: crate::AsyncObjectStore>(
store: &S,
root: ObjectId,
expected_bytes: u64,
limits: ClosureLimits,
budget: WorkBudget,
cancellation: &CancellationToken,
) -> Result<(Vec<ObjectId>, WorkCounters), GenerationProofFailure> {
let mut context = ProofContext::new(store, limits, budget, cancellation);
let logical_bytes = context
.prove_blob(root)
.await
.map_err(|error| OperationFailure::new(error, context.work))?;
if logical_bytes != expected_bytes {
return Err(OperationFailure::new(
ClosureError::BlobLengthMismatch,
context.work,
));
}
let mut objects = context.objects.into_iter().collect::<Vec<_>>();
objects.sort_unstable();
Ok((objects, context.work))
}
struct ProofContext<'a, S> {
store: &'a S,
limits: ClosureLimits,
budget: WorkBudget,
work: WorkCounters,
objects: HashSet<ObjectId>,
blob_lengths: HashMap<ObjectId, u64>,
cancellation: &'a CancellationToken,
}
impl<'a, S: crate::AsyncObjectStore> ProofContext<'a, S> {
fn new(
store: &'a S,
limits: ClosureLimits,
budget: WorkBudget,
cancellation: &'a CancellationToken,
) -> Self {
Self {
store,
limits,
budget,
work: WorkCounters::default(),
objects: HashSet::new(),
blob_lengths: HashMap::new(),
cancellation,
}
}
async fn prove(&mut self, generation_root: ObjectId) -> Result<GenerationProof, ClosureError> {
Self::require_kind(generation_root, ObjectKind::GenerationRoot)?;
let root_bytes = self.read_object(generation_root, false).await?;
let root = decode_generation_root(&root_bytes, self.limits.decode)?;
let generation_id = GenerationId::new(generation_root.digest);
let records = self.prove_file_table(root.file_table).await?;
let file_count = u64::try_from(records.len()).unwrap_or(u64::MAX);
if file_count > self.limits.maximum_files {
return Err(ClosureError::TooManyFiles {
observed: file_count,
maximum: self.limits.maximum_files,
});
}
self.prove_records_and_namespace(root.root_file_id, &records)
.await?;
let mut objects: Vec<ObjectId> = self.objects.iter().copied().collect();
objects.sort_unstable_by(|left, right| {
left.kind
.canonical_tag()
.cmp(&right.kind.canonical_tag())
.then_with(|| left.digest.as_bytes().cmp(right.digest.as_bytes()))
});
let object_bytes = u64::try_from(objects.capacity())
.unwrap_or(u64::MAX)
.saturating_mul(u64::try_from(size_of::<ObjectId>()).unwrap_or(u64::MAX));
self.work = self
.work
.checked_add(WorkCounters {
items_examined: u64::try_from(objects.len()).unwrap_or(u64::MAX),
bytes_copied: u64::try_from(objects.len())
.unwrap_or(u64::MAX)
.saturating_mul(u64::try_from(size_of::<ObjectId>()).unwrap_or(u64::MAX)),
allocation_operations: u64::from(!objects.is_empty()),
peak_allocation_bytes: object_bytes,
..WorkCounters::default()
})
.map_err(ClosureError::Work)?;
self.work.verify(self.budget).map_err(ClosureError::Work)?;
Ok(GenerationProof {
root,
generation_id,
object_count: u64::try_from(self.objects.len()).unwrap_or(u64::MAX),
file_count,
objects,
work: self.work,
})
}
async fn read_object(&mut self, object: ObjectId, page: bool) -> Result<Bytes, ClosureError> {
self.objects.insert(object);
let count = u64::try_from(self.objects.len()).unwrap_or(u64::MAX);
if count > self.limits.maximum_objects {
return Err(ClosureError::TooManyObjects {
observed: count,
maximum: self.limits.maximum_objects,
});
}
let semantic = WorkCounters {
page_reads: u64::from(page),
..WorkCounters::default()
};
let prospective = self.work.checked_add(semantic)?;
let remaining = prospective.remaining(self.budget)?;
let receipt = crate::AsyncObjectStore::read(
self.store,
object,
self.limits.decode.maximum_object_bytes,
remaining,
self.cancellation,
)
.await
.map_err(|failure| match prospective.checked_add(*failure.work) {
Ok(combined) => {
self.work = combined;
ClosureError::Storage(failure.error)
}
Err(error) => {
self.work = prospective;
ClosureError::Work(error)
}
})?;
self.work = prospective.checked_add(receipt.work)?;
self.work.verify(self.budget)?;
if self.work.object_bytes_read > self.limits.maximum_object_bytes {
return Err(ClosureError::ClosureBytesExceeded {
observed: self.work.object_bytes_read,
maximum: self.limits.maximum_object_bytes,
});
}
Ok(receipt.value.bytes)
}
async fn prove_file_table(
&mut self,
root: ObjectId,
) -> Result<BTreeMap<FileId, FileRecord>, ClosureError> {
Self::require_kind(root, ObjectKind::FileTablePage)?;
let mut records = BTreeMap::new();
let mut pending = vec![(root, None, None)];
let mut pages = HashSet::new();
while let Some((page_id, lower, upper)) = pending.pop() {
if !pages.insert(page_id) {
return Err(ClosureError::RoutingAlias);
}
let bytes = self.read_object(page_id, true).await?;
match decode_file_table_page(&bytes, self.limits.decode)? {
FileTablePage::Leaf(entries) => {
validate_file_bounds(&entries, lower, upper)?;
for record in entries {
if records.insert(record.file_id, record).is_some() {
return Err(ClosureError::DuplicateFileRecord(record.file_id));
}
}
}
FileTablePage::Internal(children) => {
validate_file_child_bounds(&children, lower, upper)?;
for (index, child) in children.iter().enumerate().rev() {
let child = *child;
let child_upper = children
.get(index + 1)
.map(|next| next.first_file_id)
.or(upper);
pending.push((child.page, Some(child.first_file_id), child_upper));
}
}
}
}
Ok(records)
}
async fn prove_records_and_namespace(
&mut self,
root_file_id: FileId,
records: &BTreeMap<FileId, FileRecord>,
) -> Result<(), ClosureError> {
let root = records
.get(&root_file_id)
.ok_or(ClosureError::MissingRootFile)?;
if !matches!(root.payload, FilePayload::Directory { .. }) {
return Err(ClosureError::RootIsNotDirectory);
}
let mut directory_entries = BTreeMap::new();
for record in records.values() {
if !record.kind.is_supported_by_profile(self.limits.profile)
|| (record.kind == super::FileKind::SymbolicLink && !self.limits.symbolic_links)
|| (record.link_count > 1 && !self.limits.hard_links)
{
return Err(ClosureError::UnsupportedVolumeSemantics);
}
self.prove_metadata(record.metadata).await?;
match record.payload {
FilePayload::Regular {
logical_bytes,
extents,
} => {
self.prove_extent_tree(extents, logical_bytes).await?;
}
FilePayload::Directory { entries } => {
directory_entries.insert(record.file_id, self.prove_tree(entries).await?);
}
FilePayload::SymbolicLink {
target_bytes,
target,
}
| FilePayload::ReparsePoint {
payload_bytes: target_bytes,
payload: target,
} => {
if self.prove_blob(target).await? != target_bytes {
return Err(ClosureError::BlobLengthMismatch);
}
}
FilePayload::InlineRegular(_) | FilePayload::Empty | FilePayload::Device { .. } => {
}
}
}
Self::validate_namespace(root_file_id, records, &directory_entries)
}
async fn prove_metadata(&mut self, metadata_id: ObjectId) -> Result<(), ClosureError> {
Self::require_kind(metadata_id, ObjectKind::Metadata)?;
if self.objects.contains(&metadata_id) {
return Ok(());
}
let bytes = self.read_object(metadata_id, false).await?;
let metadata = decode_file_metadata(&bytes, self.limits.decode)?;
if let MetadataField::Value(attributes) = metadata.named_attributes {
self.prove_attributes(attributes).await?;
}
for blob in [metadata.acl, metadata.security_descriptor] {
if let MetadataField::Value(blob) = blob {
self.prove_blob(blob).await?;
}
}
Ok(())
}
async fn prove_tree(&mut self, root: ObjectId) -> Result<Vec<TreeEntry>, ClosureError> {
Self::require_kind(root, ObjectKind::TreePage)?;
let mut entries = Vec::new();
let mut pending = vec![(root, None, None)];
let mut pages = HashSet::new();
while let Some((page_id, lower, upper)) = pending.pop() {
if !pages.insert(page_id) {
return Err(ClosureError::RoutingAlias);
}
let bytes = self.read_object(page_id, true).await?;
match decode_tree_page(&bytes, self.limits.decode)? {
TreePage::Leaf(values) => {
validate_tree_bounds(&values, lower.as_ref(), upper.as_ref())?;
entries.extend(values);
}
TreePage::Internal(children) => {
validate_tree_child_bounds(&children, lower.as_ref(), upper.as_ref())?;
for (index, child) in children.iter().enumerate().rev() {
let child = child.clone();
let child_upper = children
.get(index + 1)
.map(|next| next.first_name.clone())
.or_else(|| upper.clone());
pending.push((child.page, Some(child.first_name), child_upper));
}
}
}
}
Ok(entries)
}
async fn prove_extent_tree(&mut self, root: ObjectId, size: u64) -> Result<(), ClosureError> {
Self::require_kind(root, ObjectKind::ExtentPage)?;
let mut pending = vec![(root, 0_u64, size)];
let mut pages = HashSet::new();
while let Some((page_id, first, end)) = pending.pop() {
if !pages.insert(page_id) {
return Err(ClosureError::RoutingAlias);
}
let bytes = self.read_object(page_id, true).await?;
match decode_extent_page(&bytes, self.limits.decode)? {
ExtentPage::Leaf(extents) => {
validate_extent_bounds(&extents, first, end)?;
for extent in extents {
if !self.limits.sparse_files
&& !matches!(extent.kind, ExtentKind::Content { .. })
{
return Err(ClosureError::UnsupportedVolumeSemantics);
}
if let ExtentKind::Content {
object,
object_offset,
} = extent.kind
{
let content_end = object_offset
.checked_add(extent.length)
.ok_or(ClosureError::RangeOverflow)?;
if content_end > self.prove_blob(object).await? {
return Err(ClosureError::BlobSpanOutsideObject);
}
}
}
}
ExtentPage::Internal(children) => {
if children.first().map(|child| child.first_offset) != Some(first)
|| children.last().map(|child| child.end_offset) != Some(end)
{
return Err(ClosureError::RoutingBoundsMismatch);
}
for child in children.into_iter().rev() {
pending.push((child.page, child.first_offset, child.end_offset));
}
}
}
}
Ok(())
}
async fn prove_blob(&mut self, root: ObjectId) -> Result<u64, ClosureError> {
Self::require_kind(root, ObjectKind::Blob)?;
if let Some(length) = self.blob_lengths.get(&root) {
return Ok(*length);
}
let mut pending = vec![(root, 0_u64, None)];
let mut pages = HashSet::new();
let mut root_length = None;
while let Some((page_id, first, end)) = pending.pop() {
if !pages.insert(page_id) {
return Err(ClosureError::RoutingAlias);
}
let bytes = self.read_object(page_id, true).await?;
let page = decode_blob_page(&bytes, self.limits.decode)?;
if page.first_offset != first || end.is_some_and(|value| page.end_offset != value) {
return Err(ClosureError::RoutingBoundsMismatch);
}
if root_length.is_none() {
root_length = Some(page.end_offset);
}
match page.node {
BlobNode::Internal(children) => {
for child in children.into_iter().rev() {
pending.push((child.page, child.first_offset, Some(child.end_offset)));
}
}
BlobNode::Leaf(chunks) => {
for chunk in chunks {
let expected = chunk.end_offset - chunk.first_offset;
Self::require_kind(chunk.chunk, ObjectKind::BlobChunk)?;
let value = self.read_object(chunk.chunk, false).await?;
if u64::try_from(value.len()).unwrap_or(u64::MAX) != expected {
return Err(ClosureError::BlobLengthMismatch);
}
}
}
}
}
let length = root_length.ok_or(ClosureError::RoutingBoundsMismatch)?;
self.blob_lengths.insert(root, length);
Ok(length)
}
async fn prove_attributes(&mut self, root: ObjectId) -> Result<(), ClosureError> {
Self::require_kind(root, ObjectKind::AttributePage)?;
let mut pending = vec![(root, None, None)];
let mut pages = HashSet::new();
while let Some((page_id, lower, upper)) = pending.pop() {
if !pages.insert(page_id) {
return Err(ClosureError::RoutingAlias);
}
let bytes = self.read_object(page_id, true).await?;
match decode_attribute_page(&bytes, self.limits.decode)? {
AttributePage::Leaf(entries) => {
validate_attribute_bounds(&entries, lower.as_ref(), upper.as_ref())?;
for entry in entries {
if self.prove_blob(entry.value).await? != entry.value_bytes {
return Err(ClosureError::BlobLengthMismatch);
}
}
}
AttributePage::Internal(children) => {
validate_attribute_child_bounds(&children, lower.as_ref(), upper.as_ref())?;
for (index, child) in children.iter().enumerate().rev() {
let child = child.clone();
let child_upper = children
.get(index + 1)
.map(|next| next.first_name.clone())
.or_else(|| upper.clone());
pending.push((child.page, Some(child.first_name), child_upper));
}
}
}
}
Ok(())
}
fn validate_namespace(
root: FileId,
records: &BTreeMap<FileId, FileRecord>,
directories: &BTreeMap<FileId, Vec<TreeEntry>>,
) -> Result<(), ClosureError> {
let mut links: BTreeMap<FileId, u64> = BTreeMap::new();
links.insert(root, 1);
for entries in directories.values() {
for entry in entries {
let record = records
.get(&entry.file_id)
.ok_or(ClosureError::MissingFileRecord(entry.file_id))?;
if record.kind != entry.kind {
return Err(ClosureError::NamespaceKindMismatch(entry.file_id));
}
let count = links.entry(entry.file_id).or_default();
*count = count.checked_add(1).ok_or(ClosureError::RangeOverflow)?;
}
}
for record in records.values() {
if links.get(&record.file_id).copied().unwrap_or(0) != record.link_count {
return Err(ClosureError::LinkCountMismatch(record.file_id));
}
if matches!(record.payload, FilePayload::Directory { .. }) && record.link_count != 1 {
return Err(ClosureError::DirectoryHardLink(record.file_id));
}
}
let mut reachable = HashSet::new();
let mut visited_directories = HashSet::new();
let mut pending = vec![root];
while let Some(file_id) = pending.pop() {
if !visited_directories.insert(file_id) {
return Err(ClosureError::DirectoryHardLink(file_id));
}
reachable.insert(file_id);
let entries = directories
.get(&file_id)
.ok_or(ClosureError::MissingDirectoryTree(file_id))?;
for entry in entries {
reachable.insert(entry.file_id);
if directories.contains_key(&entry.file_id) {
pending.push(entry.file_id);
}
}
}
if reachable.len() != records.len() {
return Err(ClosureError::UnreachableFileRecords);
}
Ok(())
}
fn require_kind(object: ObjectId, expected: ObjectKind) -> Result<(), ClosureError> {
if object.kind == expected {
Ok(())
} else {
Err(ClosureError::WrongObjectKind)
}
}
}
fn validate_file_bounds(
records: &[FileRecord],
lower: Option<FileId>,
upper: Option<FileId>,
) -> Result<(), ClosureError> {
if lower.is_some_and(|value| records.first().map(|record| record.file_id) != Some(value))
|| upper.is_some_and(|value| records.last().is_some_and(|record| record.file_id >= value))
{
return Err(ClosureError::RoutingBoundsMismatch);
}
Ok(())
}
fn validate_file_child_bounds(
children: &[super::FileTableChild],
lower: Option<FileId>,
upper: Option<FileId>,
) -> Result<(), ClosureError> {
if lower.is_some_and(|value| children.first().map(|child| child.first_file_id) != Some(value))
|| upper.is_some_and(|value| {
children
.last()
.is_some_and(|child| child.first_file_id >= value)
})
{
return Err(ClosureError::RoutingBoundsMismatch);
}
Ok(())
}
fn validate_tree_bounds(
entries: &[TreeEntry],
lower: Option<&LogicalName>,
upper: Option<&LogicalName>,
) -> Result<(), ClosureError> {
if lower.is_some_and(|value| entries.first().map(|entry| &entry.name) != Some(value))
|| upper.is_some_and(|value| entries.last().is_some_and(|entry| entry.name >= *value))
{
return Err(ClosureError::RoutingBoundsMismatch);
}
Ok(())
}
fn validate_tree_child_bounds(
children: &[super::TreeChild],
lower: Option<&LogicalName>,
upper: Option<&LogicalName>,
) -> Result<(), ClosureError> {
if lower.is_some_and(|value| children.first().map(|child| &child.first_name) != Some(value))
|| upper.is_some_and(|value| {
children
.last()
.is_some_and(|child| child.first_name >= *value)
})
{
return Err(ClosureError::RoutingBoundsMismatch);
}
Ok(())
}
fn validate_extent_bounds(
extents: &[super::Extent],
first: u64,
end: u64,
) -> Result<(), ClosureError> {
if first == end && extents.is_empty() {
return Ok(());
}
if extents.first().map(|extent| extent.offset) != Some(first)
|| extents
.last()
.and_then(|extent| extent.offset.checked_add(extent.length))
!= Some(end)
{
return Err(ClosureError::RoutingBoundsMismatch);
}
Ok(())
}
fn validate_attribute_bounds(
entries: &[super::AttributeEntry],
lower: Option<&AttributeName>,
upper: Option<&AttributeName>,
) -> Result<(), ClosureError> {
if lower.is_some_and(|value| entries.first().map(|entry| &entry.name) != Some(value))
|| upper.is_some_and(|value| entries.last().is_some_and(|entry| entry.name >= *value))
{
return Err(ClosureError::RoutingBoundsMismatch);
}
Ok(())
}
fn validate_attribute_child_bounds(
children: &[super::AttributeChild],
lower: Option<&AttributeName>,
upper: Option<&AttributeName>,
) -> Result<(), ClosureError> {
if lower.is_some_and(|value| children.first().map(|child| &child.first_name) != Some(value))
|| upper.is_some_and(|value| {
children
.last()
.is_some_and(|child| child.first_name >= *value)
})
{
return Err(ClosureError::RoutingBoundsMismatch);
}
Ok(())
}
#[derive(Debug, Error)]
pub enum ClosureError {
#[error("generation closure object has the wrong kind")]
WrongObjectKind,
#[error("routing graph contains a cycle or alias")]
RoutingAlias,
#[error("routing bounds do not match authenticated child content")]
RoutingBoundsMismatch,
#[error("generation closure has {observed} objects; maximum is {maximum}")]
TooManyObjects {
observed: u64,
maximum: u64,
},
#[error("generation closure has {observed} files; maximum is {maximum}")]
TooManyFiles {
observed: u64,
maximum: u64,
},
#[error("generation closure read {observed} bytes; maximum is {maximum}")]
ClosureBytesExceeded {
observed: u64,
maximum: u64,
},
#[error("duplicate file record {0:?}")]
DuplicateFileRecord(FileId),
#[error("root file record is missing")]
MissingRootFile,
#[error("root file record is not a directory")]
RootIsNotDirectory,
#[error("generation closure uses semantics unsupported by its volume configuration")]
UnsupportedVolumeSemantics,
#[error("namespace references missing file record {0:?}")]
MissingFileRecord(FileId),
#[error("namespace kind differs from file record {0:?}")]
NamespaceKindMismatch(FileId),
#[error("file link count is incorrect for {0:?}")]
LinkCountMismatch(FileId),
#[error("directory has multiple namespace links {0:?}")]
DirectoryHardLink(FileId),
#[error("directory record has no namespace tree {0:?}")]
MissingDirectoryTree(FileId),
#[error("file table contains unreachable records")]
UnreachableFileRecords,
#[error("blob logical length does not match its declaration")]
BlobLengthMismatch,
#[error("extent content span exceeds its blob")]
BlobSpanOutsideObject,
#[error("generation closure range arithmetic overflowed")]
RangeOverflow,
#[error(transparent)]
Storage(#[from] ObjectStoreError),
#[error(transparent)]
Decode(#[from] super::CanonicalDecodeError),
#[error(transparent)]
Work(#[from] WorkError),
}
#[cfg(all(test, feature = "memory"))]
#[path = "tests/closure.rs"]
mod tests;