1use std::{
4 collections::{HashMap, HashSet},
5 fs,
6 io::{Cursor, Read, Seek},
7 path::Path,
8};
9
10use camino::Utf8PathBuf;
11use zip::ZipArchive;
12
13use crate::{
14 errors::{Diagnostic, McdError, Result},
15 manifest::Manifest,
16 search::{SearchHit, SearchOptions},
17};
18
19pub const MCD_MIMETYPE: &str = "application/vnd.mcd+zip";
21
22const MAX_FILE_COUNT: usize = 10_000;
23const MAX_SINGLE_FILE_SIZE: u64 = 64 * 1024 * 1024;
24const MAX_TOTAL_DECOMPRESSED_SIZE: u64 = 512 * 1024 * 1024;
25
26#[derive(Debug, Clone)]
28pub struct McdPackage {
29 entries: HashMap<String, Vec<u8>>,
30}
31
32impl McdPackage {
33 pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
35 let bytes = fs::read(path)?;
36 Self::from_bytes(&bytes)
37 }
38
39 pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
41 match Self::from_reader(Cursor::new(bytes)) {
42 Ok(package) => Ok(package),
43 Err(err) if is_plain_markdown_candidate(bytes) => {
44 let markdown = std::str::from_utf8(bytes).map_err(|_| err)?;
45 Ok(Self::from_markdown(markdown))
46 }
47 Err(err) => Err(err),
48 }
49 }
50
51 #[must_use]
53 pub fn from_markdown(markdown: &str) -> Self {
54 let mut entries = HashMap::new();
55 entries.insert(
56 "mimetype".to_owned(),
57 format!("{MCD_MIMETYPE}\n").into_bytes(),
58 );
59 entries.insert(
60 "manifest.json".to_owned(),
61 br#"{"format":"MCD","version":"0.1","profile":"MCD-Core","entrypoint":"content/main.md"}"#
62 .to_vec(),
63 );
64 entries.insert("content/main.md".to_owned(), markdown.as_bytes().to_vec());
65 Self { entries }
66 }
67
68 pub fn from_reader<R>(reader: R) -> Result<Self>
70 where
71 R: Read + Seek,
72 {
73 let mut archive = ZipArchive::new(reader)?;
74 if archive.len() > MAX_FILE_COUNT {
75 return Err(McdError::from_diagnostic(Diagnostic::error(
76 "package.file_count.exceeded",
77 format!("Package contains more than {MAX_FILE_COUNT} entries."),
78 )));
79 }
80
81 let mut entries = HashMap::new();
82 let mut seen = HashSet::new();
83 let mut total_size = 0_u64;
84
85 for index in 0..archive.len() {
86 let mut file = archive.by_index(index)?;
87 if file.is_dir() {
88 continue;
89 }
90
91 let name = file.name().to_owned();
92 let normalized = validate_internal_path(&name)?;
93
94 let duplicate_key = normalized.to_ascii_lowercase();
95 if !seen.insert(duplicate_key) {
96 return Err(McdError::from_diagnostic(
97 Diagnostic::error(
98 "security.path.duplicate",
99 format!("Duplicate normalized package path '{normalized}'."),
100 )
101 .with_source(name),
102 ));
103 }
104
105 let size = file.size();
106 if size > MAX_SINGLE_FILE_SIZE {
107 return Err(McdError::from_diagnostic(
108 Diagnostic::error(
109 "package.file_size.exceeded",
110 format!("Package entry '{normalized}' exceeds the single-file size limit."),
111 )
112 .with_source(normalized),
113 ));
114 }
115
116 total_size = total_size.checked_add(size).ok_or_else(|| {
117 McdError::from_diagnostic(Diagnostic::error(
118 "package.total_size.overflow",
119 "Package decompressed size overflowed.",
120 ))
121 })?;
122 if total_size > MAX_TOTAL_DECOMPRESSED_SIZE {
123 return Err(McdError::from_diagnostic(Diagnostic::error(
124 "package.total_size.exceeded",
125 format!(
126 "Package exceeds the total decompressed size limit of {MAX_TOTAL_DECOMPRESSED_SIZE} bytes."
127 ),
128 )));
129 }
130
131 let mut bytes = Vec::with_capacity(size.try_into().unwrap_or(0));
132 file.read_to_end(&mut bytes)?;
133 entries.insert(normalized, bytes);
134 }
135
136 let package = Self { entries };
137 package.validate_mimetype()?;
138 Ok(package)
139 }
140
141 #[must_use]
143 pub fn entry_paths(&self) -> Vec<&str> {
144 let mut paths = self.entries.keys().map(String::as_str).collect::<Vec<_>>();
145 paths.sort_unstable();
146 paths
147 }
148
149 #[must_use]
151 pub fn contains(&self, path: &str) -> bool {
152 self.entries.contains_key(path)
153 }
154
155 pub fn read(&self, path: &str) -> Result<&[u8]> {
157 let normalized = validate_internal_path(path)?;
158 self.entries
159 .get(&normalized)
160 .map(Vec::as_slice)
161 .ok_or_else(|| {
162 McdError::from_diagnostic(
163 Diagnostic::error(
164 "package.entry.missing",
165 format!("Package entry '{normalized}' is missing."),
166 )
167 .with_source(normalized),
168 )
169 })
170 }
171
172 pub fn read_to_string(&self, path: &str) -> Result<String> {
174 String::from_utf8(self.read(path)?.to_vec()).map_err(McdError::from)
175 }
176
177 pub fn manifest(&self) -> Result<Manifest> {
179 let bytes = self.read("manifest.json").map_err(|err| {
180 if err.diagnostic().is_some() {
181 McdError::from_diagnostic(
182 Diagnostic::error("manifest.missing", "Package is missing manifest.json.")
183 .with_source("manifest.json"),
184 )
185 } else {
186 err
187 }
188 })?;
189 Manifest::from_slice(bytes)
190 }
191
192 pub fn search(&self, query: &str, options: SearchOptions) -> Result<Vec<SearchHit>> {
194 crate::search::search_package(self, query, options)
195 }
196
197 pub fn validate_mimetype(&self) -> Result<()> {
199 let bytes = self.entries.get("mimetype").ok_or_else(|| {
200 McdError::from_diagnostic(
201 Diagnostic::error(
202 "package.mimetype.missing",
203 "Package is missing root mimetype.",
204 )
205 .with_source("mimetype"),
206 )
207 })?;
208 let mimetype = std::str::from_utf8(bytes).map_err(|_| {
209 McdError::from_diagnostic(
210 Diagnostic::error(
211 "package.mimetype.utf8",
212 "Package mimetype is not valid UTF-8.",
213 )
214 .with_source("mimetype"),
215 )
216 })?;
217 if mimetype.trim_end_matches(['\r', '\n']) != MCD_MIMETYPE {
218 return Err(McdError::from_diagnostic(
219 Diagnostic::error(
220 "package.mimetype.invalid",
221 format!("Package mimetype must be '{MCD_MIMETYPE}'."),
222 )
223 .with_source("mimetype"),
224 ));
225 }
226 Ok(())
227 }
228}
229
230fn is_plain_markdown_candidate(bytes: &[u8]) -> bool {
231 !bytes.starts_with(b"PK") && std::str::from_utf8(bytes).is_ok()
232}
233
234pub fn validate_internal_path(path: &str) -> Result<String> {
236 if path.is_empty()
237 || path.starts_with('/')
238 || path.starts_with('\\')
239 || path.contains('\\')
240 || path.contains(':')
241 || path.contains('\0')
242 {
243 return Err(invalid_path(path));
244 }
245
246 let mut normalized = Utf8PathBuf::new();
247 for component in path.split('/') {
248 if component.is_empty() || component == "." || component == ".." {
249 return Err(invalid_path(path));
250 }
251 normalized.push(component);
252 }
253
254 let normalized = normalized.as_str().replace('\\', "/");
255 if normalized != path {
256 return Err(invalid_path(path));
257 }
258
259 Ok(normalized)
260}
261
262fn invalid_path(path: &str) -> McdError {
263 McdError::from_diagnostic(
264 Diagnostic::error(
265 "security.path.invalid",
266 format!("Package path '{path}' is not a safe relative path."),
267 )
268 .with_source(path.to_owned()),
269 )
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use proptest::prelude::*;
276 use std::io::Write;
277 use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
278
279 #[test]
280 fn validates_safe_paths() {
281 assert_eq!(
282 validate_internal_path("content/main.md").expect("valid path"),
283 "content/main.md"
284 );
285 }
286
287 #[test]
288 fn rejects_traversal() {
289 let err = validate_internal_path("content/../manifest.json").expect_err("invalid path");
290 assert_eq!(
291 err.diagnostic().map(|d| d.code.as_str()),
292 Some("security.path.invalid")
293 );
294 }
295
296 #[test]
297 fn rejects_windows_separator() {
298 let err = validate_internal_path("content\\main.md").expect_err("invalid path");
299 assert_eq!(
300 err.diagnostic().map(|d| d.code.as_str()),
301 Some("security.path.invalid")
302 );
303 }
304
305 #[test]
306 fn opens_valid_minimal_package() {
307 let package = McdPackage::from_bytes(&zip_bytes(&[
308 ("mimetype", MCD_MIMETYPE),
309 (
310 "manifest.json",
311 r#"{"format":"MCD","version":"0.1","profile":"MCD-Core","entrypoint":"content/main.md"}"#,
312 ),
313 ("content/main.md", "# Minimal\n"),
314 ]))
315 .expect("package opens");
316
317 assert_eq!(
318 package.manifest().expect("manifest").entrypoint,
319 "content/main.md"
320 );
321 }
322
323 #[test]
324 fn opens_plain_markdown_as_minimal_package() {
325 let markdown = "# Plain Markdown\n\nThis file was renamed to .mcd.\n";
326 let package = McdPackage::from_bytes(markdown.as_bytes()).expect("markdown opens");
327
328 assert_eq!(
329 package.entry_paths(),
330 vec!["content/main.md", "manifest.json", "mimetype"]
331 );
332 assert_eq!(
333 package.manifest().expect("manifest").entrypoint,
334 "content/main.md"
335 );
336 assert_eq!(
337 package
338 .read_to_string("content/main.md")
339 .expect("entrypoint markdown"),
340 markdown
341 );
342 }
343
344 #[test]
345 fn missing_mimetype_fails_with_diagnostic() {
346 let err = McdPackage::from_bytes(&zip_bytes(&[(
347 "manifest.json",
348 r#"{"format":"MCD","version":"0.1","profile":"MCD-Core","entrypoint":"content/main.md"}"#,
349 )]))
350 .expect_err("missing mimetype should fail");
351
352 assert_eq!(
353 err.diagnostic().map(|d| d.code.as_str()),
354 Some("package.mimetype.missing")
355 );
356 }
357
358 #[test]
359 fn bad_mimetype_fails_with_diagnostic() {
360 let err = McdPackage::from_bytes(&zip_bytes(&[("mimetype", "text/plain")]))
361 .expect_err("bad mimetype should fail");
362
363 assert_eq!(
364 err.diagnostic().map(|d| d.code.as_str()),
365 Some("package.mimetype.invalid")
366 );
367 }
368
369 #[test]
370 fn missing_manifest_fails_with_diagnostic() {
371 let package =
372 McdPackage::from_bytes(&zip_bytes(&[("mimetype", MCD_MIMETYPE)])).expect("opens");
373 let err = package
374 .manifest()
375 .expect_err("missing manifest should fail");
376
377 assert_eq!(
378 err.diagnostic().map(|d| d.code.as_str()),
379 Some("manifest.missing")
380 );
381 }
382
383 #[test]
384 fn path_traversal_fixture_fails() {
385 let err = McdPackage::from_bytes(&zip_bytes(&[
386 ("mimetype", MCD_MIMETYPE),
387 ("../manifest.json", "{}"),
388 ]))
389 .expect_err("traversal should fail");
390
391 assert_eq!(
392 err.diagnostic().map(|d| d.code.as_str()),
393 Some("security.path.invalid")
394 );
395 }
396
397 #[test]
398 fn duplicate_normalized_path_fails() {
399 let err = McdPackage::from_bytes(&zip_bytes(&[
400 ("mimetype", MCD_MIMETYPE),
401 ("manifest.json", "{}"),
402 ("Manifest.json", "{}"),
403 ]))
404 .expect_err("duplicate path should fail");
405
406 assert_eq!(
407 err.diagnostic().map(|d| d.code.as_str()),
408 Some("security.path.duplicate")
409 );
410 }
411
412 proptest! {
413 #[test]
414 fn validates_generated_safe_relative_paths(segments in prop::collection::vec("[A-Za-z0-9_-]{1,12}", 1..5)) {
415 let path = segments.join("/");
416 let normalized = validate_internal_path(&path).expect("safe relative path should validate");
417 prop_assert_eq!(normalized, path);
418 }
419
420 #[test]
421 fn rejects_generated_traversal_paths(prefix in "[A-Za-z0-9_-]{1,12}", suffix in "[A-Za-z0-9_-]{1,12}") {
422 let path = format!("{prefix}/../{suffix}");
423 let err = validate_internal_path(&path).expect_err("traversal path should fail");
424 prop_assert_eq!(
425 err.diagnostic().map(|diagnostic| diagnostic.code.as_str()),
426 Some("security.path.invalid")
427 );
428 }
429 }
430
431 fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
432 let cursor = Cursor::new(Vec::new());
433 let mut writer = ZipWriter::new(cursor);
434 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
435
436 for (path, content) in entries {
437 writer.start_file(*path, options).expect("start file");
438 writer.write_all(content.as_bytes()).expect("write file");
439 }
440
441 writer.finish().expect("finish zip").into_inner()
442 }
443}