Skip to main content

markdown_compiler/
tree.rs

1use std::{
2    num::{NonZeroU64, NonZeroUsize},
3    path::Path,
4    sync::Arc,
5};
6
7use serde::Serialize;
8use thiserror::Error;
9
10pub(crate) use super::path::PortableLogicalPath;
11use super::{
12    ContentValidationCode, ContentValidationError, ContentValidationErrors, LogicalAssetPath,
13    LogicalContentPath, LogicalTreePathError, PostCollection, PostSource, PublicationSource,
14    ValidatedContent, validate_content,
15};
16
17#[cfg(target_os = "linux")]
18mod linux;
19
20#[cfg(test)]
21mod tests;
22
23const DEFAULT_PUBLICATION_BYTES: u64 = 256 * 1024;
24pub(crate) const DEFAULT_POST_BYTES: u64 = 4 * 1024 * 1024;
25const DEFAULT_ASSET_BYTES: u64 = 32 * 1024 * 1024;
26const DEFAULT_TREE_BYTES: u64 = 256 * 1024 * 1024;
27const DEFAULT_ENTRIES: usize = 10_000;
28const DEFAULT_DEPTH: usize = 16;
29const DEFAULT_PATH_BYTES: usize = 1_024;
30
31macro_rules! content_u64_limit {
32    ($name:ident) => {
33        #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
34        #[serde(transparent)]
35        pub struct $name(NonZeroU64);
36
37        impl $name {
38            pub const fn new(value: u64) -> Option<Self> {
39                match NonZeroU64::new(value) {
40                    Some(value) if value.get() < u64::MAX => Some(Self(value)),
41                    _ => None,
42                }
43            }
44
45            pub const fn get(self) -> u64 {
46                self.0.get()
47            }
48
49            fn default_value(value: u64) -> Self {
50                Self(NonZeroU64::new(value).unwrap_or(NonZeroU64::MIN))
51            }
52        }
53    };
54}
55
56macro_rules! content_usize_limit {
57    ($name:ident) => {
58        #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
59        #[serde(transparent)]
60        pub struct $name(NonZeroUsize);
61
62        impl $name {
63            pub const fn new(value: usize) -> Option<Self> {
64                match NonZeroUsize::new(value) {
65                    Some(value) => Some(Self(value)),
66                    None => None,
67                }
68            }
69
70            pub const fn get(self) -> usize {
71                self.0.get()
72            }
73
74            fn default_value(value: usize) -> Self {
75                Self(NonZeroUsize::new(value).unwrap_or(NonZeroUsize::MIN))
76            }
77        }
78    };
79}
80
81content_u64_limit!(ContentFileByteLimit);
82content_u64_limit!(ContentTreeByteLimit);
83content_usize_limit!(ContentEntryLimit);
84content_usize_limit!(ContentDepthLimit);
85content_usize_limit!(ContentPathByteLimit);
86
87#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
88pub struct ContentTreeLimits {
89    pub publication_file_bytes: ContentFileByteLimit,
90    pub post_file_bytes: ContentFileByteLimit,
91    pub asset_file_bytes: ContentFileByteLimit,
92    pub total_tree_bytes: ContentTreeByteLimit,
93    pub entries: ContentEntryLimit,
94    pub depth: ContentDepthLimit,
95    pub path_bytes: ContentPathByteLimit,
96}
97
98impl ContentTreeLimits {
99    pub fn new(
100        publication_file_bytes: ContentFileByteLimit,
101        post_file_bytes: ContentFileByteLimit,
102        asset_file_bytes: ContentFileByteLimit,
103        total_tree_bytes: ContentTreeByteLimit,
104        entries: ContentEntryLimit,
105        depth: ContentDepthLimit,
106        path_bytes: ContentPathByteLimit,
107    ) -> Result<Self, ContentTreeLimitsError> {
108        if [publication_file_bytes, post_file_bytes, asset_file_bytes]
109            .into_iter()
110            .any(|limit| limit.get() > total_tree_bytes.get())
111        {
112            return Err(ContentTreeLimitsError);
113        }
114        Ok(Self {
115            publication_file_bytes,
116            post_file_bytes,
117            asset_file_bytes,
118            total_tree_bytes,
119            entries,
120            depth,
121            path_bytes,
122        })
123    }
124
125    pub(crate) const fn file_limit(self, kind: ContentFileKind) -> ContentFileByteLimit {
126        match kind {
127            ContentFileKind::Publication => self.publication_file_bytes,
128            ContentFileKind::Post(_) => self.post_file_bytes,
129            ContentFileKind::Asset => self.asset_file_bytes,
130        }
131    }
132}
133
134impl Default for ContentTreeLimits {
135    fn default() -> Self {
136        Self {
137            publication_file_bytes: ContentFileByteLimit::default_value(DEFAULT_PUBLICATION_BYTES),
138            post_file_bytes: ContentFileByteLimit::default_value(DEFAULT_POST_BYTES),
139            asset_file_bytes: ContentFileByteLimit::default_value(DEFAULT_ASSET_BYTES),
140            total_tree_bytes: ContentTreeByteLimit::default_value(DEFAULT_TREE_BYTES),
141            entries: ContentEntryLimit::default_value(DEFAULT_ENTRIES),
142            depth: ContentDepthLimit::default_value(DEFAULT_DEPTH),
143            path_bytes: ContentPathByteLimit::default_value(DEFAULT_PATH_BYTES),
144        }
145    }
146}
147
148#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
149#[error("each file limit must not exceed the complete tree limit")]
150pub struct ContentTreeLimitsError;
151
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct DiscoveredPublication {
154    pub path: LogicalContentPath,
155    pub source: Box<str>,
156}
157
158#[derive(Clone, Debug, Eq, PartialEq)]
159pub struct DiscoveredPost {
160    pub path: LogicalContentPath,
161    pub collection: PostCollection,
162    pub source: Box<str>,
163}
164
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct DiscoveredAsset {
167    pub path: LogicalAssetPath,
168    pub bytes: Arc<[u8]>,
169}
170
171#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct DiscoveredContentTree {
173    pub publication: DiscoveredPublication,
174    pub posts: Vec<DiscoveredPost>,
175    pub assets: Vec<DiscoveredAsset>,
176    pub total_bytes: u64,
177}
178
179impl DiscoveredContentTree {
180    pub fn validate(&self) -> Result<ValidatedContent, ContentValidationErrors> {
181        validate_content(
182            PublicationSource::new(self.publication.path.as_str(), &self.publication.source),
183            self.posts.iter().map(|post| PostSource {
184                path: post.path.clone(),
185                contents: &post.source,
186                collection: post.collection,
187            }),
188        )
189    }
190
191    pub(crate) fn new(
192        publication: DiscoveredPublication,
193        posts: Vec<DiscoveredPost>,
194        assets: Vec<DiscoveredAsset>,
195        total_bytes: u64,
196    ) -> Self {
197        Self {
198            publication,
199            posts,
200            assets,
201            total_bytes,
202        }
203    }
204}
205
206pub fn discover_content_tree(
207    root: &Path,
208    limits: ContentTreeLimits,
209) -> Result<DiscoveredContentTree, ContentValidationErrors> {
210    discover_content_tree_with_hooks(root, limits, || {}, |_| {}, |_| {})
211}
212
213#[cfg(test)]
214fn discover_content_tree_with_hook(
215    root: &Path,
216    limits: ContentTreeLimits,
217    before_read: impl FnOnce(),
218) -> Result<DiscoveredContentTree, ContentValidationErrors> {
219    discover_content_tree_with_hooks(root, limits, before_read, |_| {}, |_| {})
220}
221
222#[cfg(target_os = "linux")]
223fn discover_content_tree_with_hooks(
224    root: &Path,
225    limits: ContentTreeLimits,
226    before_read: impl FnOnce(),
227    before_file_read: impl FnMut(&str),
228    after_file_read: impl FnMut(&str),
229) -> Result<DiscoveredContentTree, ContentValidationErrors> {
230    linux::discover(root, limits, before_read, before_file_read, after_file_read)
231}
232
233#[cfg(not(target_os = "linux"))]
234fn discover_content_tree_with_hooks(
235    _root: &Path,
236    _limits: ContentTreeLimits,
237    _before_read: impl FnOnce(),
238    _before_file_read: impl FnMut(&str),
239    _after_file_read: impl FnMut(&str),
240) -> Result<DiscoveredContentTree, ContentValidationErrors> {
241    let mut diagnostics = super::DiagnosticCollector::default();
242    diagnostics.push(ContentValidationError::new(
243        LogicalContentPath::new("<content-root>"),
244        "$path",
245        ContentValidationCode::ContentPlatformUnsupported,
246        "safe content discovery requires the supported Linux filesystem boundary",
247    ));
248    Err(diagnostics.finish())
249}
250
251#[derive(Clone, Copy, Debug, Eq, PartialEq)]
252pub(crate) enum ContentFileKind {
253    Publication,
254    Post(PostCollection),
255    Asset,
256}
257
258pub(crate) fn tree_error(
259    path: impl Into<String>,
260    code: ContentValidationCode,
261    message: impl Into<String>,
262) -> ContentValidationError {
263    ContentValidationError::new(LogicalContentPath::new(path), "$path", code, message)
264}
265
266pub(crate) fn publication(path: impl Into<String>, source: String) -> DiscoveredPublication {
267    DiscoveredPublication {
268        path: LogicalContentPath::new(path),
269        source: source.into_boxed_str(),
270    }
271}
272
273pub(crate) fn post(
274    path: impl Into<String>,
275    collection: PostCollection,
276    source: String,
277) -> DiscoveredPost {
278    DiscoveredPost {
279        path: LogicalContentPath::new(path),
280        collection,
281        source: source.into_boxed_str(),
282    }
283}
284
285pub(crate) fn asset(path: LogicalAssetPath, bytes: Vec<u8>) -> DiscoveredAsset {
286    DiscoveredAsset {
287        path,
288        bytes: Arc::from(bytes),
289    }
290}