Skip to main content

mdzip_core/
paths.rs

1//! Archive path rules: validation (Section 5.4), matching (Section 5.4.1),
2//! Markdown extensions (Section 7.3) and support files (Section 5.5).
3
4use std::collections::HashMap;
5
6use unicode_normalization::UnicodeNormalization;
7
8use crate::error::{ErrorCode, MdzError};
9
10/// Support file names (Section 5.5), compared ASCII case-insensitively, with
11/// a `.md` or `.markdown` extension.
12pub const SUPPORT_FILE_NAMES: [&str; 5] = ["readme", "agents", "license", "changelog", "contributing"];
13
14/// The path in Unicode Normalization Form C.
15pub fn nfc(path: &str) -> String {
16    path.nfc().collect()
17}
18
19/// Lowercase `A`–`Z` only, never locale-aware (Section 5.4.1).
20pub(crate) fn ascii_lower(text: &str) -> String {
21    text.to_ascii_lowercase()
22}
23
24/// Path equality per Section 5.4.1: case-sensitive, after NFC normalization.
25pub fn same_path(a: &str, b: &str) -> bool {
26    a == b || nfc(a) == nfc(b)
27}
28
29/// The stored path in `paths` that matches `wanted` (Section 5.4.1).
30pub(crate) fn find_path<'a, I>(paths: I, wanted: &str) -> Option<&'a str>
31where
32    I: IntoIterator<Item = &'a str>,
33{
34    let target = nfc(wanted);
35    paths.into_iter().find(|p| nfc(p) == target)
36}
37
38/// True for `.md` and `.markdown` files, extension compared ASCII
39/// case-insensitively (Section 7.3).
40pub fn is_markdown(path: &str) -> bool {
41    let lower = ascii_lower(path);
42    lower.ends_with(".md") || lower.ends_with(".markdown")
43}
44
45fn markdown_stem(lower: &str) -> &str {
46    lower
47        .strip_suffix(".markdown")
48        .or_else(|| lower.strip_suffix(".md"))
49        .unwrap_or(lower)
50}
51
52/// True for a root-level support file (Section 5.5): `README`, `AGENTS`,
53/// `LICENSE`, `CHANGELOG` or `CONTRIBUTING`, with `.md` or `.markdown`.
54pub fn is_support_file(path: &str) -> bool {
55    if path.contains('/') || !is_markdown(path) {
56        return false;
57    }
58    let lower = ascii_lower(path);
59    SUPPORT_FILE_NAMES.contains(&markdown_stem(&lower))
60}
61
62fn is_control(c: char) -> bool {
63    (c as u32) < 0x20 || c as u32 == 0x7f
64}
65
66fn is_reserved(c: char) -> bool {
67    matches!(c, '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
68}
69
70/// Check that `path` is a valid archive entry name (Section 5.4).
71///
72/// A trailing `/` marks a directory entry; every segment before it must be a
73/// real name. Returns the specific error code the spec's fixtures use.
74pub fn validate_path(path: &str) -> Result<(), MdzError> {
75    if path.is_empty() {
76        return Err(MdzError::new(ErrorCode::PathEmpty, "path is empty"));
77    }
78    if path.starts_with('/') {
79        return Err(MdzError::new(
80            ErrorCode::PathLeadingSlash,
81            format!("'{path}' starts with '/'"),
82        ));
83    }
84    if path.chars().any(is_control) {
85        return Err(MdzError::new(
86            ErrorCode::PathControlChar,
87            format!("{path:?} contains a control character"),
88        ));
89    }
90    if path.chars().any(is_reserved) {
91        return Err(MdzError::new(
92            ErrorCode::PathReservedChar,
93            format!("'{path}' contains a reserved character (\\ : * ? \" < > |)"),
94        ));
95    }
96    let body = path.strip_suffix('/').unwrap_or(path);
97    if body.split('/').any(|s| s == "..") {
98        return Err(MdzError::new(
99            ErrorCode::PathTraversal,
100            format!("'{path}' contains a '..' segment"),
101        ));
102    }
103    if body.split('/').any(|s| s.is_empty() || s == ".") {
104        return Err(MdzError::new(
105            ErrorCode::PathInvalid,
106            format!("'{path}' contains an empty or '.' segment"),
107        ));
108    }
109    Ok(())
110}
111
112/// Check that a set of entry names can form one archive (Section 5.4).
113///
114/// Names ending in `/` are directory entries. Two names equal after NFC are
115/// duplicates ([`ErrorCode::DuplicatePath`]); a file whose path is also a
116/// directory of another entry is a conflict ([`ErrorCode::PathConflict`]).
117pub fn validate_entry_set<'a, I>(names: I) -> Result<(), MdzError>
118where
119    I: IntoIterator<Item = &'a str>,
120{
121    let mut files: Vec<(String, &str)> = Vec::new();
122    let mut file_index: HashMap<String, &str> = HashMap::new();
123    let mut directories: HashMap<String, &str> = HashMap::new();
124    for name in names {
125        let is_dir = name.ends_with('/');
126        let key = nfc(if is_dir { &name[..name.len() - 1] } else { name });
127        if !is_dir {
128            if let Some(other) = file_index.get(&key) {
129                return Err(MdzError::new(
130                    ErrorCode::DuplicatePath,
131                    format!("'{name}' and '{other}' are the same path (Section 5.4)"),
132                ));
133            }
134            file_index.insert(key.clone(), name);
135            files.push((key.clone(), name));
136        } else {
137            directories.entry(key.clone()).or_insert(name);
138        }
139        let mut end = 0;
140        while let Some(i) = key[end..].find('/') {
141            end += i;
142            directories.entry(key[..end].to_string()).or_insert(name);
143            end += 1;
144        }
145    }
146    for (key, name) in &files {
147        if let Some(dir) = directories.get(key) {
148            return Err(MdzError::new(
149                ErrorCode::PathConflict,
150                format!("'{name}' is a file and also a directory of '{dir}' (Section 5.4)"),
151            ));
152        }
153    }
154    Ok(())
155}
156
157/// Strip a single leading `./` from an entry point or cover value.
158pub(crate) fn strip_dot_slash(value: &str) -> &str {
159    value.strip_prefix("./").unwrap_or(value)
160}
161
162/// Why a manifest `entryPoint` string isn't a valid entry point path
163/// (Section 5.5), or `None` if it is.
164pub(crate) fn entry_point_problem(value: &str) -> Option<String> {
165    if value.is_empty() {
166        return Some("entryPoint must be a non-empty string".into());
167    }
168    let path = strip_dot_slash(value);
169    if path.ends_with('/') {
170        return Some(format!("entryPoint '{value}' names a directory"));
171    }
172    if let Err(e) = validate_path(path) {
173        return Some(format!(
174            "entryPoint '{value}' is not a valid archive path ({})",
175            e.message()
176        ));
177    }
178    if !is_markdown(path) {
179        return Some(format!("entryPoint '{value}' is not a .md or .markdown file"));
180    }
181    None
182}
183
184/// Why a manifest `cover` string isn't a valid path (Section 6.2), or `None`.
185pub(crate) fn cover_problem(value: &str) -> Option<String> {
186    if value.is_empty() {
187        return Some("cover must be a non-empty string".into());
188    }
189    let path = strip_dot_slash(value);
190    if path.ends_with('/') {
191        return Some(format!("cover '{value}' names a directory"));
192    }
193    validate_path(path)
194        .err()
195        .map(|e| format!("cover '{value}' is not a valid archive path ({})", e.message()))
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn support_files_are_ascii_case_insensitive() {
204        assert!(is_support_file("README.md"));
205        assert!(is_support_file("readme.MARKDOWN"));
206        assert!(is_support_file("AGENTS.MD"));
207        assert!(!is_support_file("docs/README.md"));
208        assert!(!is_support_file("README-draft.md"));
209        // Turkish dotted/dotless i must not match (Section 5.4.1, T-010).
210        assert!(!is_support_file("L\u{130}CENSE.md"));
211    }
212
213    #[test]
214    fn nfc_and_nfd_match_but_case_does_not() {
215        assert!(same_path("caf\u{e9}.md", "cafe\u{301}.md"));
216        assert!(!same_path("Index.md", "index.md"));
217    }
218}