Skip to main content

semora_cli/
checkout.rs

1//! What this checkout last pulled or pushed for each document of its project, so `pull` can
2//! tell a file changed here from one changed on the server, and a keyed push can name the
3//! revision it started from.
4//!
5//! The record is `.semora-state/checkout.json`, next to `.semora`. It belongs to the folder, not
6//! to the machine, so every worktree has its own. The folder holds a `.gitignore` of `*`, so it
7//! never gets committed.
8
9use std::collections::BTreeMap;
10use std::fs::OpenOptions;
11use std::io::ErrorKind;
12use std::path::{Path, PathBuf};
13
14use anyhow::Context;
15use semora_proto::DocKind;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::project_link::{Link, shown};
20
21pub const DIR: &str = ".semora-state";
22const FILE: &str = "checkout.json";
23const IGNORE: &str =
24    "# Semora's record of what this folder pulled and pushed. Not for committing.\n*\n";
25
26/// One document as this checkout last saw it.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Record {
29    pub id: String,
30    pub rev: i64,
31    pub kind: DocKind,
32    /// The sha256 of each file, by its path from the project folder.
33    pub files: BTreeMap<String, String>,
34}
35
36/// The records of a project, by document path.
37pub type Records = BTreeMap<String, Record>;
38
39#[derive(Debug, Default, Serialize, Deserialize)]
40struct Stored {
41    server: String,
42    project: String,
43    #[serde(default)]
44    docs: Records,
45}
46
47pub fn sha256(data: &[u8]) -> String {
48    format!("{:x}", Sha256::digest(data))
49}
50
51fn file(link: &Link) -> PathBuf {
52    link.root.join(DIR).join(FILE)
53}
54
55fn read(link: &Link) -> anyhow::Result<Records> {
56    let path = file(link);
57    let text = match std::fs::read_to_string(&path) {
58        Ok(t) => t,
59        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Records::new()),
60        Err(e) => return Err(e).with_context(|| format!("reading {}", shown(&path))),
61    };
62    let stored: Stored = serde_json::from_str(&text).with_context(|| {
63        format!(
64            "{} is unreadable; delete it and run `semora pull` to start the record again",
65            shown(&path)
66        )
67    })?;
68    // Records for another server or project (the `.semora` changed) say nothing about this one.
69    if stored.server != link.server || stored.project != link.slug {
70        return Ok(Records::new());
71    }
72    Ok(stored.docs)
73}
74
75/// The records of this checkout's project.
76pub fn load(link: &Link) -> anyhow::Result<Records> {
77    read(link)
78}
79
80/// Changes the records under a lock, so pushes and pulls in one checkout don't lose each
81/// other's writes.
82pub fn update<T>(link: &Link, change: impl FnOnce(&mut Records) -> T) -> anyhow::Result<T> {
83    let dir = link.root.join(DIR);
84    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", shown(&dir)))?;
85    let ignore = dir.join(".gitignore");
86    if !ignore.exists() {
87        std::fs::write(&ignore, IGNORE).with_context(|| format!("writing {}", shown(&ignore)))?;
88    }
89    let lock_path = dir.join("lock");
90    let lock = OpenOptions::new()
91        .write(true)
92        .create(true)
93        .truncate(false)
94        .open(&lock_path)
95        .with_context(|| format!("opening {}", shown(&lock_path)))?;
96    lock.lock()
97        .with_context(|| format!("locking {}", shown(&lock_path)))?;
98    let mut docs = read(link)?;
99    let out = change(&mut docs);
100    let stored = Stored {
101        server: link.server.clone(),
102        project: link.slug.clone(),
103        docs,
104    };
105    let text = serde_json::to_string_pretty(&stored).expect("records serialize");
106    // Readers don't lock: the rename swaps in the whole file at once.
107    let tmp = dir.join(format!("{FILE}.tmp"));
108    std::fs::write(&tmp, text).with_context(|| format!("writing {}", shown(&tmp)))?;
109    let path = file(link);
110    std::fs::rename(&tmp, &path).with_context(|| format!("writing {}", shown(&path)))?;
111    Ok(out)
112}
113
114/// A path from the server as a relative path on this machine, or `None` when it would leave
115/// the project folder, reach into `.git`, `.semora` or the record, or can't be a file name here.
116pub fn local_rel(path: &str) -> Option<PathBuf> {
117    if path.is_empty()
118        || path
119            .chars()
120            .any(|c| c.is_control() || c == '\\' || c == ':')
121    {
122        return None;
123    }
124    let mut out = PathBuf::new();
125    for seg in path.split('/') {
126        if seg.is_empty() || seg == "." || seg == ".." {
127            return None;
128        }
129        let lower = seg.to_ascii_lowercase();
130        if [".git", crate::project_link::FILE, DIR].contains(&lower.as_str()) {
131            return None;
132        }
133        if cfg!(windows) && !windows_name(seg) {
134            return None;
135        }
136        out.push(seg);
137    }
138    Some(out)
139}
140
141/// Characters, endings and device names Windows refuses in a file name.
142fn windows_name(seg: &str) -> bool {
143    const DEVICES: [&str; 22] = [
144        "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
145        "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
146    ];
147    let stem = seg.split('.').next().unwrap_or("").to_ascii_lowercase();
148    !seg.contains(['<', '>', '"', '|', '?', '*'])
149        && !seg.ends_with(['.', ' '])
150        && !DEVICES.contains(&stem.as_str())
151}
152
153/// What is on disk at a document's path.
154#[derive(Debug, PartialEq, Eq)]
155pub enum Local {
156    Missing,
157    /// The sha256 of each file a push would send, by path from the project folder.
158    Files(BTreeMap<String, String>),
159    /// A folder where the document is a file, or the other way round.
160    WrongType,
161}
162
163/// Reads the document at `key` as a push would: one file, or the files of a folder.
164pub fn local(root: &Path, key: &str, kind: DocKind) -> anyhow::Result<Local> {
165    let Some(rel) = local_rel(key) else {
166        return Ok(Local::WrongType);
167    };
168    let full = root.join(rel);
169    let meta = match std::fs::metadata(&full) {
170        Ok(m) => m,
171        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Local::Missing),
172        // A file where one of the path's folders should be.
173        Err(e) if e.kind() == ErrorKind::NotADirectory => return Ok(Local::WrongType),
174        Err(e) => return Err(e).with_context(|| format!("reading {}", shown(&full))),
175    };
176    let mut files = BTreeMap::new();
177    match (kind, meta.is_dir()) {
178        (DocKind::Bundle, true) => {
179            let (list, _) = crate::bundle::list(&full)?;
180            for f in list {
181                let data = std::fs::read(full.join(&f))
182                    .with_context(|| format!("reading {}", shown(&full.join(&f))))?;
183                files.insert(format!("{key}/{f}"), sha256(&data));
184            }
185        }
186        (DocKind::Bundle, false) | (DocKind::Md | DocKind::Html, true) => {
187            return Ok(Local::WrongType);
188        }
189        (DocKind::Md | DocKind::Html, false) => {
190            let data = std::fs::read(&full).with_context(|| format!("reading {}", shown(&full)))?;
191            files.insert(key.to_string(), sha256(&data));
192        }
193    }
194    Ok(Local::Files(files))
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn link(root: &Path) -> Link {
202        Link {
203            root: root.to_path_buf(),
204            server: "https://a.example.com".into(),
205            slug: "p".into(),
206        }
207    }
208
209    fn record(rev: i64) -> Record {
210        Record {
211            id: "k3F9xQ2mLp".into(),
212            rev,
213            kind: DocKind::Md,
214            files: [("docs/a.md".to_string(), sha256(b"# A"))].into(),
215        }
216    }
217
218    #[test]
219    fn records_round_trip_and_the_folder_ignores_itself() {
220        let d = tempfile::tempdir().unwrap();
221        let l = link(d.path());
222        assert!(load(&l).unwrap().is_empty());
223        update(&l, |r| r.insert("docs/a.md".into(), record(4))).unwrap();
224        assert_eq!(load(&l).unwrap()["docs/a.md"], record(4));
225        let ignore = std::fs::read_to_string(d.path().join(DIR).join(".gitignore")).unwrap();
226        assert!(ignore.lines().any(|l| l == "*"), "{ignore}");
227    }
228
229    #[test]
230    fn records_of_another_project_or_server_are_not_used() {
231        let d = tempfile::tempdir().unwrap();
232        let l = link(d.path());
233        update(&l, |r| r.insert("docs/a.md".into(), record(4))).unwrap();
234        let other = Link {
235            slug: "q".into(),
236            ..l.clone()
237        };
238        assert!(load(&other).unwrap().is_empty());
239        let elsewhere = Link {
240            server: "https://b.example.com".into(),
241            ..l.clone()
242        };
243        assert!(load(&elsewhere).unwrap().is_empty());
244        update(&other, |r| r.insert("x.md".into(), record(1))).unwrap();
245        assert!(
246            load(&l).unwrap().is_empty(),
247            "the file now holds q's records"
248        );
249    }
250
251    #[test]
252    fn an_unreadable_record_says_how_to_start_again() {
253        let d = tempfile::tempdir().unwrap();
254        std::fs::create_dir(d.path().join(DIR)).unwrap();
255        std::fs::write(d.path().join(DIR).join(FILE), "{not json").unwrap();
256        let err = format!("{:#}", load(&link(d.path())).unwrap_err());
257        assert!(err.contains("semora pull") && err.contains(FILE), "{err}");
258    }
259
260    #[test]
261    fn updates_at_the_same_time_keep_every_record() {
262        let d = tempfile::tempdir().unwrap();
263        let l = link(d.path());
264        let n = 16;
265        let start = std::sync::Barrier::new(n);
266        std::thread::scope(|s| {
267            for i in 0..n {
268                let (start, l) = (&start, &l);
269                s.spawn(move || {
270                    start.wait();
271                    update(l, |r| r.insert(format!("f{i}.md"), record(i as i64))).unwrap();
272                });
273            }
274        });
275        assert_eq!(load(&l).unwrap().len(), n);
276    }
277
278    #[test]
279    fn server_paths_stay_inside_the_project_folder() {
280        assert_eq!(
281            local_rel("docs/plan.md"),
282            Some(Path::new("docs").join("plan.md"))
283        );
284        assert!(
285            local_rel(".github/notes.md").is_some(),
286            "a hidden folder is fine"
287        );
288        for bad in [
289            "",
290            "/etc/passwd",
291            "../x.md",
292            "docs/../../x.md",
293            "docs//x.md",
294            "./x.md",
295            "a\\b.md",
296            "C:/x.md",
297            "x\n.md",
298            ".git/hooks/x.md",
299            "sub/.GIT/config.md",
300            ".semora",
301            ".semora-state/checkout.json",
302        ] {
303            assert_eq!(local_rel(bad), None, "{bad:?}");
304        }
305    }
306
307    #[test]
308    fn local_reads_a_file_or_a_folder_as_a_push_would() {
309        let d = tempfile::tempdir().unwrap();
310        let root = d.path();
311        assert_eq!(local(root, "a.md", DocKind::Md).unwrap(), Local::Missing);
312        std::fs::write(root.join("a.md"), "# A").unwrap();
313        assert_eq!(
314            local(root, "a.md", DocKind::Md).unwrap(),
315            Local::Files([("a.md".to_string(), sha256(b"# A"))].into())
316        );
317        assert_eq!(
318            local(root, "a.md", DocKind::Bundle).unwrap(),
319            Local::WrongType
320        );
321        std::fs::create_dir_all(root.join("site/.cache")).unwrap();
322        std::fs::write(root.join("site/index.md"), "# I").unwrap();
323        std::fs::write(root.join("site/.cache/x.md"), "hidden").unwrap();
324        std::fs::write(root.join("site/notes.txt"), "not served").unwrap();
325        assert_eq!(
326            local(root, "site", DocKind::Bundle).unwrap(),
327            Local::Files([("site/index.md".to_string(), sha256(b"# I"))].into()),
328            "only what a push would send"
329        );
330        assert_eq!(local(root, "site", DocKind::Md).unwrap(), Local::WrongType);
331        assert_eq!(
332            local(root, "../a.md", DocKind::Md).unwrap(),
333            Local::WrongType
334        );
335    }
336}