markdown-compiler 0.1.0

Strict Markdown content validation, asset resolution, and revision identities for Maincopy
Documentation
use std::{
    num::{NonZeroU64, NonZeroUsize},
    path::Path,
    sync::Arc,
};

use serde::Serialize;
use thiserror::Error;

pub(crate) use super::path::PortableLogicalPath;
use super::{
    ContentValidationCode, ContentValidationError, ContentValidationErrors, LogicalAssetPath,
    LogicalContentPath, LogicalTreePathError, PostCollection, PostSource, PublicationSource,
    ValidatedContent, validate_content,
};

#[cfg(target_os = "linux")]
mod linux;

#[cfg(test)]
mod tests;

const DEFAULT_PUBLICATION_BYTES: u64 = 256 * 1024;
pub(crate) const DEFAULT_POST_BYTES: u64 = 4 * 1024 * 1024;
const DEFAULT_ASSET_BYTES: u64 = 32 * 1024 * 1024;
const DEFAULT_TREE_BYTES: u64 = 256 * 1024 * 1024;
const DEFAULT_ENTRIES: usize = 10_000;
const DEFAULT_DEPTH: usize = 16;
const DEFAULT_PATH_BYTES: usize = 1_024;

macro_rules! content_u64_limit {
    ($name:ident) => {
        #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
        #[serde(transparent)]
        pub struct $name(NonZeroU64);

        impl $name {
            pub const fn new(value: u64) -> Option<Self> {
                match NonZeroU64::new(value) {
                    Some(value) if value.get() < u64::MAX => Some(Self(value)),
                    _ => None,
                }
            }

            pub const fn get(self) -> u64 {
                self.0.get()
            }

            fn default_value(value: u64) -> Self {
                Self(NonZeroU64::new(value).unwrap_or(NonZeroU64::MIN))
            }
        }
    };
}

macro_rules! content_usize_limit {
    ($name:ident) => {
        #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
        #[serde(transparent)]
        pub struct $name(NonZeroUsize);

        impl $name {
            pub const fn new(value: usize) -> Option<Self> {
                match NonZeroUsize::new(value) {
                    Some(value) => Some(Self(value)),
                    None => None,
                }
            }

            pub const fn get(self) -> usize {
                self.0.get()
            }

            fn default_value(value: usize) -> Self {
                Self(NonZeroUsize::new(value).unwrap_or(NonZeroUsize::MIN))
            }
        }
    };
}

content_u64_limit!(ContentFileByteLimit);
content_u64_limit!(ContentTreeByteLimit);
content_usize_limit!(ContentEntryLimit);
content_usize_limit!(ContentDepthLimit);
content_usize_limit!(ContentPathByteLimit);

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct ContentTreeLimits {
    pub publication_file_bytes: ContentFileByteLimit,
    pub post_file_bytes: ContentFileByteLimit,
    pub asset_file_bytes: ContentFileByteLimit,
    pub total_tree_bytes: ContentTreeByteLimit,
    pub entries: ContentEntryLimit,
    pub depth: ContentDepthLimit,
    pub path_bytes: ContentPathByteLimit,
}

impl ContentTreeLimits {
    pub fn new(
        publication_file_bytes: ContentFileByteLimit,
        post_file_bytes: ContentFileByteLimit,
        asset_file_bytes: ContentFileByteLimit,
        total_tree_bytes: ContentTreeByteLimit,
        entries: ContentEntryLimit,
        depth: ContentDepthLimit,
        path_bytes: ContentPathByteLimit,
    ) -> Result<Self, ContentTreeLimitsError> {
        if [publication_file_bytes, post_file_bytes, asset_file_bytes]
            .into_iter()
            .any(|limit| limit.get() > total_tree_bytes.get())
        {
            return Err(ContentTreeLimitsError);
        }
        Ok(Self {
            publication_file_bytes,
            post_file_bytes,
            asset_file_bytes,
            total_tree_bytes,
            entries,
            depth,
            path_bytes,
        })
    }

    pub(crate) const fn file_limit(self, kind: ContentFileKind) -> ContentFileByteLimit {
        match kind {
            ContentFileKind::Publication => self.publication_file_bytes,
            ContentFileKind::Post(_) => self.post_file_bytes,
            ContentFileKind::Asset => self.asset_file_bytes,
        }
    }
}

impl Default for ContentTreeLimits {
    fn default() -> Self {
        Self {
            publication_file_bytes: ContentFileByteLimit::default_value(DEFAULT_PUBLICATION_BYTES),
            post_file_bytes: ContentFileByteLimit::default_value(DEFAULT_POST_BYTES),
            asset_file_bytes: ContentFileByteLimit::default_value(DEFAULT_ASSET_BYTES),
            total_tree_bytes: ContentTreeByteLimit::default_value(DEFAULT_TREE_BYTES),
            entries: ContentEntryLimit::default_value(DEFAULT_ENTRIES),
            depth: ContentDepthLimit::default_value(DEFAULT_DEPTH),
            path_bytes: ContentPathByteLimit::default_value(DEFAULT_PATH_BYTES),
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
#[error("each file limit must not exceed the complete tree limit")]
pub struct ContentTreeLimitsError;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiscoveredPublication {
    pub path: LogicalContentPath,
    pub source: Box<str>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiscoveredPost {
    pub path: LogicalContentPath,
    pub collection: PostCollection,
    pub source: Box<str>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiscoveredAsset {
    pub path: LogicalAssetPath,
    pub bytes: Arc<[u8]>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiscoveredContentTree {
    pub publication: DiscoveredPublication,
    pub posts: Vec<DiscoveredPost>,
    pub assets: Vec<DiscoveredAsset>,
    pub total_bytes: u64,
}

impl DiscoveredContentTree {
    pub fn validate(&self) -> Result<ValidatedContent, ContentValidationErrors> {
        validate_content(
            PublicationSource::new(self.publication.path.as_str(), &self.publication.source),
            self.posts.iter().map(|post| PostSource {
                path: post.path.clone(),
                contents: &post.source,
                collection: post.collection,
            }),
        )
    }

    pub(crate) fn new(
        publication: DiscoveredPublication,
        posts: Vec<DiscoveredPost>,
        assets: Vec<DiscoveredAsset>,
        total_bytes: u64,
    ) -> Self {
        Self {
            publication,
            posts,
            assets,
            total_bytes,
        }
    }
}

pub fn discover_content_tree(
    root: &Path,
    limits: ContentTreeLimits,
) -> Result<DiscoveredContentTree, ContentValidationErrors> {
    discover_content_tree_with_hooks(root, limits, || {}, |_| {}, |_| {})
}

#[cfg(test)]
fn discover_content_tree_with_hook(
    root: &Path,
    limits: ContentTreeLimits,
    before_read: impl FnOnce(),
) -> Result<DiscoveredContentTree, ContentValidationErrors> {
    discover_content_tree_with_hooks(root, limits, before_read, |_| {}, |_| {})
}

#[cfg(target_os = "linux")]
fn discover_content_tree_with_hooks(
    root: &Path,
    limits: ContentTreeLimits,
    before_read: impl FnOnce(),
    before_file_read: impl FnMut(&str),
    after_file_read: impl FnMut(&str),
) -> Result<DiscoveredContentTree, ContentValidationErrors> {
    linux::discover(root, limits, before_read, before_file_read, after_file_read)
}

#[cfg(not(target_os = "linux"))]
fn discover_content_tree_with_hooks(
    _root: &Path,
    _limits: ContentTreeLimits,
    _before_read: impl FnOnce(),
    _before_file_read: impl FnMut(&str),
    _after_file_read: impl FnMut(&str),
) -> Result<DiscoveredContentTree, ContentValidationErrors> {
    let mut diagnostics = super::DiagnosticCollector::default();
    diagnostics.push(ContentValidationError::new(
        LogicalContentPath::new("<content-root>"),
        "$path",
        ContentValidationCode::ContentPlatformUnsupported,
        "safe content discovery requires the supported Linux filesystem boundary",
    ));
    Err(diagnostics.finish())
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ContentFileKind {
    Publication,
    Post(PostCollection),
    Asset,
}

pub(crate) fn tree_error(
    path: impl Into<String>,
    code: ContentValidationCode,
    message: impl Into<String>,
) -> ContentValidationError {
    ContentValidationError::new(LogicalContentPath::new(path), "$path", code, message)
}

pub(crate) fn publication(path: impl Into<String>, source: String) -> DiscoveredPublication {
    DiscoveredPublication {
        path: LogicalContentPath::new(path),
        source: source.into_boxed_str(),
    }
}

pub(crate) fn post(
    path: impl Into<String>,
    collection: PostCollection,
    source: String,
) -> DiscoveredPost {
    DiscoveredPost {
        path: LogicalContentPath::new(path),
        collection,
        source: source.into_boxed_str(),
    }
}

pub(crate) fn asset(path: LogicalAssetPath, bytes: Vec<u8>) -> DiscoveredAsset {
    DiscoveredAsset {
        path,
        bytes: Arc::from(bytes),
    }
}