1use std::collections::BTreeMap;
4use std::path::Path;
5
6use anyhow::Context;
7
8pub fn allowed(path: &str) -> bool {
10 let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
11 path.contains('.')
12 && matches!(
13 ext.as_str(),
14 "md" | "markdown"
15 | "html"
16 | "htm"
17 | "css"
18 | "js"
19 | "mjs"
20 | "json"
21 | "svg"
22 | "png"
23 | "jpg"
24 | "jpeg"
25 | "gif"
26 | "webp"
27 | "avif"
28 | "bmp"
29 | "ico"
30 | "woff"
31 | "woff2"
32 | "ttf"
33 | "otf"
34 | "zip"
35 | "7z"
36 | "gz"
37 | "rar"
38 )
39}
40
41pub fn is_page(path: &str) -> bool {
42 let p = path.to_ascii_lowercase();
43 p.ends_with(".md") || p.ends_with(".markdown") || p.ends_with(".html") || p.ends_with(".htm")
44}
45
46fn skip_dir(name: &str) -> bool {
48 name.starts_with('.') || name == "node_modules"
49}
50
51pub type Skipped = Vec<(String, &'static str)>;
53
54pub struct Packed {
55 pub tar: Vec<u8>,
56 pub files: Vec<String>,
57 pub hashes: BTreeMap<String, String>,
59 pub skipped: Skipped,
60}
61
62pub fn list(root: &Path) -> anyhow::Result<(Vec<String>, Skipped)> {
64 let mut files = Vec::new();
65 let mut skipped = Vec::new();
66 walk(root, "", &mut files, &mut skipped)?;
67 files.sort();
68 Ok((files, skipped))
69}
70
71pub fn pack_dir(root: &Path) -> anyhow::Result<Packed> {
72 let (files, skipped) = list(root)?;
73 let mut hashes = BTreeMap::new();
74 let mut builder = tar::Builder::new(Vec::new());
75 for rel in &files {
76 let data = std::fs::read(root.join(rel)).with_context(|| format!("reading {rel}"))?;
77 hashes.insert(rel.clone(), crate::checkout::sha256(&data));
78 let mut h = tar::Header::new_gnu();
79 h.set_size(data.len() as u64);
80 h.set_mode(0o644);
81 h.set_entry_type(tar::EntryType::Regular);
82 h.set_mtime(0);
83 builder
84 .append_data(&mut h, rel, data.as_slice())
85 .with_context(|| format!("adding {rel}"))?;
86 }
87 let tar = builder.into_inner()?;
88 Ok(Packed {
89 tar,
90 files,
91 hashes,
92 skipped,
93 })
94}
95
96fn walk(
97 root: &Path,
98 rel: &str,
99 files: &mut Vec<String>,
100 skipped: &mut Vec<(String, &'static str)>,
101) -> anyhow::Result<()> {
102 let dir = if rel.is_empty() {
103 root.to_path_buf()
104 } else {
105 root.join(rel)
106 };
107 let mut entries: Vec<_> = std::fs::read_dir(&dir)
108 .with_context(|| format!("reading {}", dir.display()))?
109 .collect::<Result<_, _>>()?;
110 entries.sort_by_key(|e| e.file_name());
111 for e in entries {
112 let name = match e.file_name().into_string() {
115 Ok(name) => name,
116 Err(raw) => {
117 let shown = raw.to_string_lossy();
118 let path = if rel.is_empty() {
119 shown.into_owned()
120 } else {
121 format!("{rel}/{shown}")
122 };
123 skipped.push((path, "the name is not valid UTF-8"));
124 continue;
125 }
126 };
127 let path = if rel.is_empty() {
128 name.clone()
129 } else {
130 format!("{rel}/{name}")
131 };
132 let ft = e.file_type()?;
133 let kind = if ft.is_symlink() {
134 Kind::Link
135 } else if ft.is_dir() {
136 Kind::Dir
137 } else {
138 Kind::File
139 };
140 match sort_entry(kind, &name, &path) {
141 Entry::Pack => files.push(path),
142 Entry::Enter => walk(root, &path, files, skipped)?,
143 Entry::Skip(why) => skipped.push((path, why)),
144 Entry::Ignore => {}
145 }
146 }
147 Ok(())
148}
149
150#[derive(Clone, Copy)]
152enum Kind {
153 Link,
154 Dir,
155 File,
156}
157
158#[derive(Debug, PartialEq)]
160enum Entry {
161 Pack,
163 Enter,
165 Skip(&'static str),
167 Ignore,
169}
170
171fn sort_entry(kind: Kind, name: &str, path: &str) -> Entry {
174 match kind {
175 Kind::Link => Entry::Skip("symbolic link"),
176 Kind::Dir if skip_dir(name) => Entry::Ignore,
177 Kind::Dir if name.contains(':') => {
180 Entry::Skip("a colon in the folder name; everything in it is skipped")
181 }
182 Kind::Dir => match unservable(name, path) {
183 Some(why) => Entry::Skip(why),
184 None => Entry::Enter,
185 },
186 Kind::File if name.starts_with('.') => Entry::Ignore,
188 Kind::File if name.contains(':') => Entry::Skip("a colon in the file name"),
189 Kind::File => match unservable(name, path) {
190 Some(why) => Entry::Skip(why),
191 None if !allowed(path) => Entry::Skip("file type not served"),
192 None => Entry::Pack,
193 },
194 }
195}
196
197const MAX_PATH_BYTES: usize = 512;
199
200fn unservable(name: &str, path: &str) -> Option<&'static str> {
204 if name.contains('\\') {
205 Some("a backslash in the name")
206 } else if name.chars().any(char::is_control) {
207 Some("a control character in the name")
208 } else if path.len() > MAX_PATH_BYTES {
209 Some("the path is over 512 bytes")
210 } else {
211 None
212 }
213}
214
215pub fn local_page_links(md: &str) -> Vec<String> {
217 let mut out = Vec::new();
218 let mut rest = md;
219 while let Some(i) = rest.find("](") {
220 rest = &rest[i + 2..];
221 let end = rest.find([')', ' ', '\n']).unwrap_or(rest.len());
222 let href = rest[..end].trim_matches(['<', '>']);
223 let path = href.split(['#', '?']).next().unwrap_or("");
224 let external = href.contains("://")
225 || href.starts_with("mailto:")
226 || href.starts_with('#')
227 || href.starts_with('/');
228 if !external && is_page(path) {
229 out.push(href.to_string());
230 }
231 rest = &rest[end..];
232 }
233 out
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn packs_and_skips() {
242 let dir = tempfile::tempdir().unwrap();
243 let w = |p: &str| {
244 let full = dir.path().join(p);
245 std::fs::create_dir_all(full.parent().unwrap()).unwrap();
246 std::fs::write(full, "x").unwrap();
247 };
248 for p in [
249 "index.md",
250 "a/b.png",
251 ".env",
252 ".git/HEAD",
253 "node_modules/m/i.js",
254 "notes.txt",
255 "a/.hidden.md",
256 ] {
257 w(p);
258 }
259 let packed = pack_dir(dir.path()).unwrap();
260 assert_eq!(packed.files, vec!["a/b.png", "index.md"]);
261 assert_eq!(
262 packed.skipped,
263 vec![("notes.txt".to_string(), "file type not served")]
264 );
265 let mut archive = tar::Archive::new(packed.tar.as_slice());
266 let names: Vec<String> = archive
267 .entries()
268 .unwrap()
269 .map(|e| {
270 e.unwrap()
271 .path()
272 .unwrap()
273 .to_string_lossy()
274 .replace('\\', "/")
275 })
276 .collect();
277 assert_eq!(names, vec!["a/b.png", "index.md"]);
278 }
279
280 #[test]
282 fn a_name_with_a_colon_is_skipped_with_a_reason() {
283 let file = Entry::Skip("a colon in the file name");
284 assert_eq!(sort_entry(Kind::File, "a:b.md", "a:b.md"), file);
285 assert_eq!(sort_entry(Kind::File, "a:b.md", "docs/a:b.md"), file);
286 assert_eq!(sort_entry(Kind::File, "notes:", "notes:"), file);
287 let dir = Entry::Skip("a colon in the folder name; everything in it is skipped");
288 assert_eq!(sort_entry(Kind::Dir, "v1:final", "v1:final"), dir);
289 assert_eq!(sort_entry(Kind::Dir, "v1:final", "docs/v1:final"), dir);
290 assert_eq!(sort_entry(Kind::File, "a.md", "a.md"), Entry::Pack);
292 assert_eq!(
293 sort_entry(Kind::File, "a;b-c_d.md", "a;b-c_d.md"),
294 Entry::Pack
295 );
296 assert_eq!(sort_entry(Kind::Dir, "docs", "docs"), Entry::Enter);
297 assert_eq!(
298 sort_entry(Kind::File, "a.txt", "a.txt"),
299 Entry::Skip("file type not served")
300 );
301 assert_eq!(
302 sort_entry(Kind::Link, "a.md", "a.md"),
303 Entry::Skip("symbolic link")
304 );
305 assert_eq!(
307 sort_entry(Kind::File, "a\\b.md", "a\\b.md"),
308 Entry::Skip("a backslash in the name")
309 );
310 assert_eq!(
311 sort_entry(Kind::Dir, "tab\there", "docs/tab\there"),
312 Entry::Skip("a control character in the name")
313 );
314 let long = format!("{}.md", "a".repeat(200));
315 let deep = format!("{0}/{0}/{long}", "d".repeat(160));
316 assert_eq!(sort_entry(Kind::File, &long, &long), Entry::Pack);
317 assert_eq!(
318 sort_entry(Kind::File, &long, &deep),
319 Entry::Skip("the path is over 512 bytes")
320 );
321 assert_eq!(sort_entry(Kind::File, ".a:b", ".a:b"), Entry::Ignore);
323 assert_eq!(sort_entry(Kind::Dir, ".a:b", ".a:b"), Entry::Ignore);
324 assert_eq!(
325 sort_entry(Kind::Dir, "node_modules", "node_modules"),
326 Entry::Ignore
327 );
328 }
329
330 #[cfg(unix)]
332 #[test]
333 fn a_folder_with_colon_names_packs_the_rest() {
334 let dir = tempfile::tempdir().unwrap();
335 let w = |p: &str| {
336 let full = dir.path().join(p);
337 std::fs::create_dir_all(full.parent().unwrap()).unwrap();
338 std::fs::write(full, "x").unwrap();
339 };
340 for p in [
341 "index.md",
342 "a:b.md",
343 "docs/guide.md",
344 "docs/c:d.png",
345 "v1:final/one.md",
346 "v1:final/deeper/two.md",
347 ] {
348 w(p);
349 }
350 let packed = pack_dir(dir.path()).unwrap();
351 assert_eq!(packed.files, vec!["docs/guide.md", "index.md"]);
352 let skipped: Vec<&str> = packed.skipped.iter().map(|(p, _)| p.as_str()).collect();
353 assert_eq!(skipped, vec!["a:b.md", "docs/c:d.png", "v1:final"]);
354 let mut archive = tar::Archive::new(packed.tar.as_slice());
355 let names: Vec<String> = archive
356 .entries()
357 .unwrap()
358 .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
359 .collect();
360 assert_eq!(names, vec!["docs/guide.md", "index.md"]);
361 }
362
363 #[cfg(target_os = "linux")]
365 #[test]
366 fn a_name_that_is_not_utf8_is_skipped_and_the_rest_is_packed() {
367 use std::ffi::OsStr;
368 use std::os::unix::ffi::OsStrExt;
369 let dir = tempfile::tempdir().unwrap();
370 std::fs::write(dir.path().join("index.md"), "x").unwrap();
371 std::fs::write(dir.path().join(OsStr::from_bytes(b"caf\xe9.md")), "x").unwrap();
372 let bad_dir = dir.path().join(OsStr::from_bytes(b"d\xff"));
373 std::fs::create_dir(&bad_dir).unwrap();
374 std::fs::write(bad_dir.join("inside.md"), "x").unwrap();
375 let packed = pack_dir(dir.path()).unwrap();
376 assert_eq!(packed.files, vec!["index.md"]);
377 assert_eq!(
378 packed.skipped,
379 vec![
380 ("caf\u{fffd}.md".to_string(), "the name is not valid UTF-8"),
381 ("d\u{fffd}".to_string(), "the name is not valid UTF-8"),
382 ]
383 );
384 }
385
386 #[test]
387 fn finds_local_page_links() {
388 let md = "[a](guide.md) [b](https://x/y.md) [c](img.png) [d](sub/page.html#top) [e](#here)";
389 assert_eq!(local_page_links(md), vec!["guide.md", "sub/page.html#top"]);
390 }
391}