1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Record {
29 pub id: String,
30 pub rev: i64,
31 pub kind: DocKind,
32 pub files: BTreeMap<String, String>,
34}
35
36pub 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 if stored.server != link.server || stored.project != link.slug {
70 return Ok(Records::new());
71 }
72 Ok(stored.docs)
73}
74
75pub fn load(link: &Link) -> anyhow::Result<Records> {
77 read(link)
78}
79
80pub 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 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
114pub 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
141fn 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#[derive(Debug, PartialEq, Eq)]
155pub enum Local {
156 Missing,
157 Files(BTreeMap<String, String>),
159 WrongType,
161}
162
163pub 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 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}