Skip to main content

semora_cli/
bundle.rs

1//! Packing a folder into a tar for a bundle push.
2
3use std::collections::BTreeMap;
4use std::path::Path;
5
6use anyhow::Context;
7
8/// Extensions the server accepts in a bundle.
9pub 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
46/// Folders never worth uploading.
47fn skip_dir(name: &str) -> bool {
48    name.starts_with('.') || name == "node_modules"
49}
50
51/// Files left out on purpose, with the reason.
52pub type Skipped = Vec<(String, &'static str)>;
53
54pub struct Packed {
55    pub tar: Vec<u8>,
56    pub files: Vec<String>,
57    /// The sha256 of each packed file, by its path in the folder.
58    pub hashes: BTreeMap<String, String>,
59    pub skipped: Skipped,
60}
61
62/// The files a push of `root` would send (sorted), and those it would leave out.
63pub 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 = e.file_name().to_string_lossy().into_owned();
113        let path = if rel.is_empty() {
114            name.clone()
115        } else {
116            format!("{rel}/{name}")
117        };
118        let ft = e.file_type()?;
119        if ft.is_symlink() {
120            skipped.push((path, "symbolic link"));
121        } else if ft.is_dir() {
122            if !skip_dir(&name) {
123                walk(root, &path, files, skipped)?;
124            }
125        } else if name.starts_with('.') {
126            // Hidden files stay local.
127        } else if !allowed(&path) {
128            skipped.push((path, "file type not served"));
129        } else {
130            files.push(path);
131        }
132    }
133    Ok(())
134}
135
136/// Relative links from a Markdown file to other local pages (for the bundle hint).
137pub fn local_page_links(md: &str) -> Vec<String> {
138    let mut out = Vec::new();
139    let mut rest = md;
140    while let Some(i) = rest.find("](") {
141        rest = &rest[i + 2..];
142        let end = rest.find([')', ' ', '\n']).unwrap_or(rest.len());
143        let href = rest[..end].trim_matches(['<', '>']);
144        let path = href.split(['#', '?']).next().unwrap_or("");
145        let external = href.contains("://")
146            || href.starts_with("mailto:")
147            || href.starts_with('#')
148            || href.starts_with('/');
149        if !external && is_page(path) {
150            out.push(href.to_string());
151        }
152        rest = &rest[end..];
153    }
154    out
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn packs_and_skips() {
163        let dir = tempfile::tempdir().unwrap();
164        let w = |p: &str| {
165            let full = dir.path().join(p);
166            std::fs::create_dir_all(full.parent().unwrap()).unwrap();
167            std::fs::write(full, "x").unwrap();
168        };
169        for p in [
170            "index.md",
171            "a/b.png",
172            ".env",
173            ".git/HEAD",
174            "node_modules/m/i.js",
175            "notes.txt",
176            "a/.hidden.md",
177        ] {
178            w(p);
179        }
180        let packed = pack_dir(dir.path()).unwrap();
181        assert_eq!(packed.files, vec!["a/b.png", "index.md"]);
182        assert_eq!(
183            packed.skipped,
184            vec![("notes.txt".to_string(), "file type not served")]
185        );
186        let mut archive = tar::Archive::new(packed.tar.as_slice());
187        let names: Vec<String> = archive
188            .entries()
189            .unwrap()
190            .map(|e| {
191                e.unwrap()
192                    .path()
193                    .unwrap()
194                    .to_string_lossy()
195                    .replace('\\', "/")
196            })
197            .collect();
198        assert_eq!(names, vec!["a/b.png", "index.md"]);
199    }
200
201    #[test]
202    fn finds_local_page_links() {
203        let md = "[a](guide.md) [b](https://x/y.md) [c](img.png) [d](sub/page.html#top) [e](#here)";
204        assert_eq!(local_page_links(md), vec!["guide.md", "sub/page.html#top"]);
205    }
206}