Skip to main content

libmandoc_rs/
source_bundle.rs

1//! Bounded, read-only virtual source trees for cross-platform `.so` expansion.
2
3use std::{collections::BTreeMap, fmt};
4
5/// Maximum number of sources retained by one [`SourceBundle`].
6pub const MAX_SOURCE_BUNDLE_FILES: usize = 4_096;
7
8/// Maximum size of one uncompressed source in a [`SourceBundle`].
9pub const MAX_SOURCE_BUNDLE_FILE_BYTES: usize = 16 * 1024 * 1024;
10
11/// Maximum aggregate uncompressed size of one [`SourceBundle`].
12pub const MAX_SOURCE_BUNDLE_BYTES: usize = 64 * 1024 * 1024;
13
14/// Categorizes a rejected virtual source without exposing implementation details.
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum SourceBundleErrorKind {
17    /// The logical source path is empty, absolute, or contains unsafe components.
18    InvalidPath,
19    /// One source exceeds [`MAX_SOURCE_BUNDLE_FILE_BYTES`].
20    SourceTooLarge,
21    /// The bundle would exceed [`MAX_SOURCE_BUNDLE_FILES`].
22    TooManySources,
23    /// The bundle would exceed [`MAX_SOURCE_BUNDLE_BYTES`].
24    BundleTooLarge,
25}
26
27/// Failure to add one source to a [`SourceBundle`].
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct SourceBundleError {
30    path: String,
31    kind: SourceBundleErrorKind,
32    message: String,
33}
34
35impl SourceBundleError {
36    /// Return the rejected logical path.
37    #[must_use]
38    pub fn path(&self) -> &str {
39        &self.path
40    }
41
42    /// Return the stable failure category.
43    #[must_use]
44    pub const fn kind(&self) -> SourceBundleErrorKind {
45        self.kind
46    }
47}
48
49impl fmt::Display for SourceBundleError {
50    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51        write!(formatter, "{}: {}", self.path, self.message)
52    }
53}
54
55impl std::error::Error for SourceBundleError {}
56
57/// Bounded, immutable-at-parse-time collection of uncompressed roff sources.
58///
59/// Paths use `/` separators and are exact relative logical identities. Empty
60/// components, `.`, `..`, absolute paths, backslashes, and NUL bytes are
61/// rejected. The native parser can only resolve `.so` requests to entries in
62/// this collection; it never falls back to the host filesystem.
63#[derive(Clone, Debug, Default, Eq, PartialEq)]
64pub struct SourceBundle {
65    sources: BTreeMap<String, Vec<u8>>,
66    total_bytes: usize,
67}
68
69impl SourceBundle {
70    /// Create an empty source bundle.
71    #[must_use]
72    pub const fn new() -> Self {
73        Self {
74            sources: BTreeMap::new(),
75            total_bytes: 0,
76        }
77    }
78
79    /// Return the number of logical sources in this bundle.
80    #[must_use]
81    pub fn len(&self) -> usize {
82        self.sources.len()
83    }
84
85    /// Return whether this bundle contains no sources.
86    #[must_use]
87    pub fn is_empty(&self) -> bool {
88        self.sources.is_empty()
89    }
90
91    /// Return the aggregate uncompressed byte size.
92    #[must_use]
93    pub const fn total_bytes(&self) -> usize {
94        self.total_bytes
95    }
96
97    /// Return one exact logical source.
98    #[must_use]
99    pub fn get(&self, path: &str) -> Option<&[u8]> {
100        self.sources.get(path).map(Vec::as_slice)
101    }
102
103    /// Insert or replace one uncompressed source.
104    ///
105    /// The previous bytes are returned when `path` already existed. Failed
106    /// insertions leave the bundle unchanged.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`SourceBundleError`] when the logical path is unsafe or a
111    /// documented source-count or byte limit would be exceeded.
112    pub fn insert(
113        &mut self,
114        path: impl Into<String>,
115        source: impl Into<Vec<u8>>,
116    ) -> Result<Option<Vec<u8>>, SourceBundleError> {
117        let path = path.into();
118        validate_path(&path)?;
119        let source = source.into();
120        if source.len() > MAX_SOURCE_BUNDLE_FILE_BYTES {
121            return Err(error(
122                path,
123                SourceBundleErrorKind::SourceTooLarge,
124                format!(
125                    "source has {} bytes; the maximum is {MAX_SOURCE_BUNDLE_FILE_BYTES}",
126                    source.len()
127                ),
128            ));
129        }
130
131        let previous_len = self.sources.get(&path).map_or(0, Vec::len);
132        if previous_len == 0
133            && !self.sources.contains_key(&path)
134            && self.sources.len() == MAX_SOURCE_BUNDLE_FILES
135        {
136            return Err(error(
137                path,
138                SourceBundleErrorKind::TooManySources,
139                format!("bundle already contains {MAX_SOURCE_BUNDLE_FILES} sources"),
140            ));
141        }
142        let next_total = self
143            .total_bytes
144            .checked_sub(previous_len)
145            .and_then(|total| total.checked_add(source.len()))
146            .filter(|total| *total <= MAX_SOURCE_BUNDLE_BYTES)
147            .ok_or_else(|| {
148                error(
149                    path.clone(),
150                    SourceBundleErrorKind::BundleTooLarge,
151                    format!("bundle would exceed {MAX_SOURCE_BUNDLE_BYTES} bytes"),
152                )
153            })?;
154
155        let previous = self.sources.insert(path, source);
156        self.total_bytes = next_total;
157        Ok(previous)
158    }
159
160    pub(crate) fn sources(&self) -> impl ExactSizeIterator<Item = (&str, &[u8])> {
161        self.sources
162            .iter()
163            .map(|(path, source)| (path.as_str(), source.as_slice()))
164    }
165}
166
167fn validate_path(path: &str) -> Result<(), SourceBundleError> {
168    let invalid = path.is_empty()
169        || path.starts_with('/')
170        || path.contains(['\\', '\0'])
171        || path
172            .split('/')
173            .any(|component| component.is_empty() || matches!(component, "." | ".."));
174    if invalid {
175        Err(error(
176            path.to_owned(),
177            SourceBundleErrorKind::InvalidPath,
178            "logical paths must be normalized relative paths using '/' separators".to_owned(),
179        ))
180    } else {
181        Ok(())
182    }
183}
184
185fn error(path: String, kind: SourceBundleErrorKind, message: String) -> SourceBundleError {
186    SourceBundleError {
187        path,
188        kind,
189        message,
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::{
196        MAX_SOURCE_BUNDLE_BYTES, MAX_SOURCE_BUNDLE_FILE_BYTES, MAX_SOURCE_BUNDLE_FILES,
197        SourceBundle, SourceBundleErrorKind,
198    };
199
200    #[test]
201    fn bundle_paths_are_exact_relative_posix_identities() {
202        let mut bundle = SourceBundle::new();
203        bundle
204            .insert("man3/foo.3", b"foo".to_vec())
205            .expect("insert normalized source");
206        assert_eq!(bundle.get("man3/foo.3"), Some(b"foo".as_slice()));
207
208        for path in [
209            "",
210            "/foo.1",
211            "foo\\bar.1",
212            "foo//bar.1",
213            "./foo.1",
214            "../foo.1",
215        ] {
216            let error = bundle
217                .insert(path, Vec::new())
218                .expect_err("reject unsafe logical path");
219            assert_eq!(error.kind(), SourceBundleErrorKind::InvalidPath, "{path:?}");
220        }
221    }
222
223    #[test]
224    fn replacing_a_source_updates_the_aggregate_size_transactionally() {
225        let mut bundle = SourceBundle::new();
226        assert_eq!(bundle.insert("foo.1", b"one".to_vec()), Ok(None));
227        assert_eq!(bundle.total_bytes(), 3);
228        assert_eq!(
229            bundle.insert("foo.1", b"replacement".to_vec()),
230            Ok(Some(b"one".to_vec()))
231        );
232        assert_eq!(bundle.total_bytes(), b"replacement".len());
233    }
234
235    #[test]
236    fn documented_bundle_limits_are_enforced_without_partial_mutation() {
237        let mut bundle = SourceBundle::new();
238        let error = bundle
239            .insert("oversized.1", vec![0; MAX_SOURCE_BUNDLE_FILE_BYTES + 1])
240            .expect_err("reject oversized source");
241        assert_eq!(error.kind(), SourceBundleErrorKind::SourceTooLarge);
242        assert!(bundle.is_empty());
243
244        for index in 0..MAX_SOURCE_BUNDLE_FILES {
245            bundle
246                .insert(format!("source-{index}.1"), Vec::new())
247                .expect("fill source count exactly");
248        }
249        let error = bundle
250            .insert("one-too-many.1", Vec::new())
251            .expect_err("reject source beyond count cap");
252        assert_eq!(error.kind(), SourceBundleErrorKind::TooManySources);
253        assert_eq!(bundle.len(), MAX_SOURCE_BUNDLE_FILES);
254
255        let mut aggregate = SourceBundle::new();
256        for index in 0..MAX_SOURCE_BUNDLE_BYTES / MAX_SOURCE_BUNDLE_FILE_BYTES {
257            aggregate
258                .insert(
259                    format!("large-{index}.1"),
260                    vec![0; MAX_SOURCE_BUNDLE_FILE_BYTES],
261                )
262                .expect("fill aggregate byte limit exactly");
263        }
264        let error = aggregate
265            .insert("aggregate-overflow.1", vec![0])
266            .expect_err("reject aggregate byte overflow");
267        assert_eq!(error.kind(), SourceBundleErrorKind::BundleTooLarge);
268        assert_eq!(aggregate.total_bytes(), MAX_SOURCE_BUNDLE_BYTES);
269        assert_eq!(aggregate.len(), 4);
270    }
271}