ic-query 0.91.0

Internet Computer query library for CloudEngine, NNS, SNS, ICRC, system canisters, and public network metadata
Documentation
//! Module: cache_file::confined::read
//!
//! Responsibility: confined managed-file existence checks and bounded reads.
//! Does not own: path confinement, cache schemas, or atomic publication.
//! Boundary: opens only validated regular files beneath a capability root.

use super::{CacheFileError, ConfinedCacheRoot};
use ic_host_artifacts::artifact::{ArtifactError, read_reader};
use std::{
    io::{self, Read},
    path::{Path, PathBuf},
};

///
/// BoundedManagedFileReadError
///
/// Mechanical failure while reading one confined managed file under an explicit byte ceiling.
///

#[derive(Debug)]
pub enum BoundedManagedFileReadError {
    /// Opening or validating the managed path failed.
    Operation(CacheFileError),
    /// Reading file metadata or bytes failed after the path was opened.
    Read {
        /// Managed file being read.
        path: PathBuf,
        /// Underlying filesystem failure.
        source: io::Error,
    },
    /// The file exceeded the caller-selected byte ceiling.
    LimitExceeded {
        /// Managed file that exceeded its ceiling.
        path: PathBuf,
        /// Observed metadata or streamed byte length.
        actual: u64,
        /// Caller-selected maximum byte length.
        maximum: u64,
    },
    /// A platform byte count could not be represented safely.
    Accounting {
        /// Managed file whose byte count could not be represented.
        path: PathBuf,
    },
}

///
/// ManagedReadBudget
///
/// Aggregate byte allowance shared by every file read in one local inspection.
///

#[derive(Debug)]
pub struct ManagedReadBudget {
    maximum: u64,
    remaining: u64,
}

impl ManagedReadBudget {
    /// Set the total byte allowance for one inspection operation.
    #[cfg(any(feature = "sns-host", test))]
    pub const fn new(maximum: u64) -> Self {
        Self {
            maximum,
            remaining: maximum,
        }
    }
}

/// Return whether a confined regular managed file exists.
pub fn managed_file_exists(cache_root: &Path, target_path: &Path) -> Result<bool, CacheFileError> {
    open_managed_file(cache_root, target_path).map(|file| file.is_some())
}

/// Open a confined regular managed file without following symbolic links.
pub fn open_managed_file(
    cache_root: &Path,
    target_path: &Path,
) -> Result<Option<std::fs::File>, CacheFileError> {
    let Some(root) = ConfinedCacheRoot::open(cache_root, false)? else {
        return Ok(None);
    };
    let Some(target) = root.resolve_parent(target_path, false)? else {
        return Ok(None);
    };
    target.open_regular_file()
}

/// Read under a per-file ceiling and an optional shared inspection allowance.
#[cfg(any(
    feature = "nns-host",
    feature = "subnet-catalog-host",
    feature = "dashboard-host",
    feature = "nns-topology-host",
    feature = "icrc-host",
    feature = "sns-host",
    test
))]
pub fn read_bounded_managed_file(
    cache_root: &Path,
    target_path: &Path,
    maximum: u64,
    budget: Option<&mut ManagedReadBudget>,
) -> Result<Option<Vec<u8>>, BoundedManagedFileReadError> {
    let Some(file) = open_managed_file(cache_root, target_path)
        .map_err(BoundedManagedFileReadError::Operation)?
    else {
        return Ok(None);
    };
    read_opened_file_bounded(file, target_path, maximum, budget).map(Some)
}

fn read_budgeted_stream(
    reader: impl Read,
    metadata_length: u64,
    target_path: &Path,
    maximum: u64,
    budget: &mut ManagedReadBudget,
) -> Result<Vec<u8>, BoundedManagedFileReadError> {
    if metadata_length > maximum {
        return read_bounded_stream(reader, metadata_length, target_path, maximum);
    }
    let aggregate_maximum = budget.maximum;
    let exhausted = || {
        BoundedManagedFileReadError::Operation(CacheFileError::ScanLimitExceeded {
            path: target_path.to_path_buf(),
            resource: "inspection bytes",
            maximum: aggregate_maximum,
        })
    };
    if metadata_length > budget.remaining {
        return Err(exhausted());
    }
    let ceiling = maximum.min(budget.remaining);
    let result = read_bounded_stream(reader, metadata_length, target_path, ceiling);
    match &result {
        Ok(data) => budget.remaining -= data.len() as u64,
        Err(BoundedManagedFileReadError::LimitExceeded { actual, .. })
            if *actual > ceiling && ceiling == budget.remaining =>
        {
            budget.remaining = 0;
            return Err(exhausted());
        }
        Err(BoundedManagedFileReadError::Read { .. }) => {
            // Partial IO failures still consume work; conservatively charge the ceiling.
            budget.remaining = budget.remaining.saturating_sub(ceiling.saturating_add(1));
        }
        Err(BoundedManagedFileReadError::LimitExceeded { .. }) => budget.remaining -= ceiling + 1,
        Err(_) => {}
    }
    result
}

pub fn read_opened_file_bounded(
    file: std::fs::File,
    target_path: &Path,
    maximum: u64,
    budget: Option<&mut ManagedReadBudget>,
) -> Result<Vec<u8>, BoundedManagedFileReadError> {
    let metadata_length = file
        .metadata()
        .map_err(|source| BoundedManagedFileReadError::Read {
            path: target_path.to_path_buf(),
            source,
        })?
        .len();
    match budget {
        Some(budget) => read_budgeted_stream(file, metadata_length, target_path, maximum, budget),
        None => read_bounded_stream(file, metadata_length, target_path, maximum),
    }
}

pub fn read_bounded_stream(
    reader: impl Read,
    metadata_length: u64,
    target_path: &Path,
    maximum: u64,
) -> Result<Vec<u8>, BoundedManagedFileReadError> {
    if metadata_length > maximum {
        return Err(BoundedManagedFileReadError::LimitExceeded {
            path: target_path.to_path_buf(),
            actual: metadata_length,
            maximum,
        });
    }
    let accounting = || BoundedManagedFileReadError::Accounting {
        path: target_path.to_path_buf(),
    };
    usize::try_from(metadata_length).map_err(|_| accounting())?;
    // An unbounded u64 allowance still admits small streams on smaller hosts.
    let host_maximum = usize::try_from(maximum).unwrap_or(usize::MAX);
    read_reader(reader, host_maximum).map_err(|error| match error {
        ArtifactError::Io(source) => BoundedManagedFileReadError::Read {
            path: target_path.to_path_buf(),
            source,
        },
        ArtifactError::Allocation(source) => BoundedManagedFileReadError::Read {
            path: target_path.to_path_buf(),
            source: io::Error::new(io::ErrorKind::OutOfMemory, source),
        },
        ArtifactError::LimitExceeded { limit } => match limit.checked_add(1) {
            Some(actual) if actual > maximum => BoundedManagedFileReadError::LimitExceeded {
                path: target_path.to_path_buf(),
                actual,
                maximum,
            },
            _ => accounting(),
        },
        source @ (ArtifactError::NotRegularFile
        | ArtifactError::DigestMismatch { .. }
        | ArtifactError::LengthMismatch { .. }) => BoundedManagedFileReadError::Read {
            path: target_path.to_path_buf(),
            source: io::Error::new(io::ErrorKind::InvalidData, source),
        },
    })
}

#[cfg(test)]
mod tests;