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_stored(link: &Link) -> anyhow::Result<Option<Stored>> {
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(None),
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 Ok(Some(stored))
69}
70
71fn read(link: &Link) -> anyhow::Result<Records> {
72 Ok(read_stored(link)?
74 .filter(|s| s.server == link.server && s.project == link.slug)
75 .map(|s| s.docs)
76 .unwrap_or_default())
77}
78
79pub fn ids_under_other_id(link: &Link) -> Vec<String> {
83 match read_stored(link) {
84 Ok(Some(s)) if s.server == link.server && s.project != link.slug => {
85 s.docs.into_values().map(|r| r.id).collect()
86 }
87 _ => Vec::new(),
88 }
89}
90
91pub fn relabel(link: &Link) -> anyhow::Result<()> {
93 let Some(stored) = read_stored(link)? else {
94 return Ok(());
95 };
96 if stored.server != link.server {
97 return Ok(());
98 }
99 let docs = stored.docs;
100 update(link, |r| {
101 for (k, v) in docs {
102 r.entry(k).or_insert(v);
103 }
104 })
105}
106
107pub fn load(link: &Link) -> anyhow::Result<Records> {
109 read(link)
110}
111
112pub fn move_to(old: &Link, new: &Link) -> anyhow::Result<()> {
115 let docs = read(old)?;
116 if docs.is_empty() {
117 return Ok(());
118 }
119 update(new, |r| {
120 for (k, v) in docs {
121 r.entry(k).or_insert(v);
122 }
123 })
124}
125
126pub fn update<T>(link: &Link, change: impl FnOnce(&mut Records) -> T) -> anyhow::Result<T> {
129 let dir = link.root.join(DIR);
130 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", shown(&dir)))?;
131 let ignore = dir.join(".gitignore");
132 if !ignore.exists() {
133 std::fs::write(&ignore, IGNORE).with_context(|| format!("writing {}", shown(&ignore)))?;
134 }
135 let lock_path = dir.join("lock");
136 let lock = OpenOptions::new()
137 .write(true)
138 .create(true)
139 .truncate(false)
140 .open(&lock_path)
141 .with_context(|| format!("opening {}", shown(&lock_path)))?;
142 lock.lock()
143 .with_context(|| format!("locking {}", shown(&lock_path)))?;
144 let mut docs = read(link)?;
145 let out = change(&mut docs);
146 let stored = Stored {
147 server: link.server.clone(),
148 project: link.slug.clone(),
149 docs,
150 };
151 let text = serde_json::to_string_pretty(&stored).expect("records serialize");
152 let tmp = dir.join(format!("{FILE}.tmp"));
154 std::fs::write(&tmp, text).with_context(|| format!("writing {}", shown(&tmp)))?;
155 let path = file(link);
156 std::fs::rename(&tmp, &path).with_context(|| format!("writing {}", shown(&path)))?;
157 Ok(out)
158}
159
160pub fn local_rel(path: &str) -> Option<PathBuf> {
163 if path.is_empty()
164 || path
165 .chars()
166 .any(|c| c.is_control() || c == '\\' || c == ':')
167 {
168 return None;
169 }
170 let mut out = PathBuf::new();
171 for seg in path.split('/') {
172 if seg.is_empty() || seg == "." || seg == ".." {
173 return None;
174 }
175 let lower = seg.to_ascii_lowercase();
176 if [".git", crate::project_link::FILE, DIR].contains(&lower.as_str()) {
177 return None;
178 }
179 if cfg!(windows) && !windows_name(seg) {
180 return None;
181 }
182 out.push(seg);
183 }
184 Some(out)
185}
186
187fn windows_name(seg: &str) -> bool {
189 const DEVICES: [&str; 22] = [
190 "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
191 "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
192 ];
193 let stem = seg.split('.').next().unwrap_or("").to_ascii_lowercase();
194 !seg.contains(['<', '>', '"', '|', '?', '*'])
195 && !seg.ends_with(['.', ' '])
196 && !DEVICES.contains(&stem.as_str())
197}
198
199#[derive(Debug, PartialEq, Eq)]
201pub enum Local {
202 Missing,
203 Files(BTreeMap<String, String>),
205 WrongType,
207}
208
209pub fn local(root: &Path, key: &str, kind: DocKind) -> anyhow::Result<Local> {
211 let Some(rel) = local_rel(key) else {
212 return Ok(Local::WrongType);
213 };
214 let full = root.join(rel);
215 let meta = match std::fs::metadata(&full) {
216 Ok(m) => m,
217 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Local::Missing),
218 Err(e) if e.kind() == ErrorKind::NotADirectory => return Ok(Local::WrongType),
220 Err(e) => return Err(e).with_context(|| format!("reading {}", shown(&full))),
221 };
222 let mut files = BTreeMap::new();
223 match (kind, meta.is_dir()) {
224 (DocKind::Bundle, true) => {
225 let (list, _) = crate::bundle::list(&full)?;
226 for f in list {
227 let data = std::fs::read(full.join(&f))
228 .with_context(|| format!("reading {}", shown(&full.join(&f))))?;
229 files.insert(format!("{key}/{f}"), sha256(&data));
230 }
231 }
232 (DocKind::Bundle, false) | (DocKind::Md | DocKind::Html, true) => {
233 return Ok(Local::WrongType);
234 }
235 (DocKind::Md | DocKind::Html, false) => {
236 let data = std::fs::read(&full).with_context(|| format!("reading {}", shown(&full)))?;
237 files.insert(key.to_string(), sha256(&data));
238 }
239 }
240 Ok(Local::Files(files))
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 fn link(root: &Path) -> Link {
248 Link {
249 root: root.to_path_buf(),
250 server: "https://a.example.com".into(),
251 slug: "p".into(),
252 file_slug: "p".into(),
253 local: None,
254 }
255 }
256
257 fn record(rev: i64) -> Record {
258 Record {
259 id: "k3F9xQ2mLp".into(),
260 rev,
261 kind: DocKind::Md,
262 files: [("docs/a.md".to_string(), sha256(b"# A"))].into(),
263 }
264 }
265
266 #[test]
267 fn records_round_trip_and_the_folder_ignores_itself() {
268 let d = tempfile::tempdir().unwrap();
269 let l = link(d.path());
270 assert!(load(&l).unwrap().is_empty());
271 update(&l, |r| r.insert("docs/a.md".into(), record(4))).unwrap();
272 assert_eq!(load(&l).unwrap()["docs/a.md"], record(4));
273 let ignore = std::fs::read_to_string(d.path().join(DIR).join(".gitignore")).unwrap();
274 assert!(ignore.lines().any(|l| l == "*"), "{ignore}");
275 }
276
277 #[test]
278 fn records_of_another_project_or_server_are_not_used() {
279 let d = tempfile::tempdir().unwrap();
280 let l = link(d.path());
281 update(&l, |r| r.insert("docs/a.md".into(), record(4))).unwrap();
282 let other = Link {
283 slug: "q".into(),
284 ..l.clone()
285 };
286 assert!(load(&other).unwrap().is_empty());
287 let elsewhere = Link {
288 server: "https://b.example.com".into(),
289 ..l.clone()
290 };
291 assert!(load(&elsewhere).unwrap().is_empty());
292 update(&other, |r| r.insert("x.md".into(), record(1))).unwrap();
293 assert!(
294 load(&l).unwrap().is_empty(),
295 "the file now holds q's records"
296 );
297 }
298
299 #[test]
300 fn an_unreadable_record_says_how_to_start_again() {
301 let d = tempfile::tempdir().unwrap();
302 std::fs::create_dir(d.path().join(DIR)).unwrap();
303 std::fs::write(d.path().join(DIR).join(FILE), "{not json").unwrap();
304 let err = format!("{:#}", load(&link(d.path())).unwrap_err());
305 assert!(err.contains("semora pull") && err.contains(FILE), "{err}");
306 }
307
308 #[test]
309 fn updates_at_the_same_time_keep_every_record() {
310 let d = tempfile::tempdir().unwrap();
311 let l = link(d.path());
312 let n = 16;
313 let start = std::sync::Barrier::new(n);
314 std::thread::scope(|s| {
315 for i in 0..n {
316 let (start, l) = (&start, &l);
317 s.spawn(move || {
318 start.wait();
319 update(l, |r| r.insert(format!("f{i}.md"), record(i as i64))).unwrap();
320 });
321 }
322 });
323 assert_eq!(load(&l).unwrap().len(), n);
324 }
325
326 #[test]
327 fn server_paths_stay_inside_the_project_folder() {
328 assert_eq!(
329 local_rel("docs/plan.md"),
330 Some(Path::new("docs").join("plan.md"))
331 );
332 assert!(
333 local_rel(".github/notes.md").is_some(),
334 "a hidden folder is fine"
335 );
336 for bad in [
337 "",
338 "/etc/passwd",
339 "../x.md",
340 "docs/../../x.md",
341 "docs//x.md",
342 "./x.md",
343 "a\\b.md",
344 "C:/x.md",
345 "x\n.md",
346 ".git/hooks/x.md",
347 "sub/.GIT/config.md",
348 ".semora",
349 ".semora-state/checkout.json",
350 ] {
351 assert_eq!(local_rel(bad), None, "{bad:?}");
352 }
353 }
354
355 #[test]
356 fn local_reads_a_file_or_a_folder_as_a_push_would() {
357 let d = tempfile::tempdir().unwrap();
358 let root = d.path();
359 assert_eq!(local(root, "a.md", DocKind::Md).unwrap(), Local::Missing);
360 std::fs::write(root.join("a.md"), "# A").unwrap();
361 assert_eq!(
362 local(root, "a.md", DocKind::Md).unwrap(),
363 Local::Files([("a.md".to_string(), sha256(b"# A"))].into())
364 );
365 assert_eq!(
366 local(root, "a.md", DocKind::Bundle).unwrap(),
367 Local::WrongType
368 );
369 std::fs::create_dir_all(root.join("site/.cache")).unwrap();
370 std::fs::write(root.join("site/index.md"), "# I").unwrap();
371 std::fs::write(root.join("site/.cache/x.md"), "hidden").unwrap();
372 std::fs::write(root.join("site/notes.txt"), "not served").unwrap();
373 assert_eq!(
374 local(root, "site", DocKind::Bundle).unwrap(),
375 Local::Files([("site/index.md".to_string(), sha256(b"# I"))].into()),
376 "only what a push would send"
377 );
378 assert_eq!(local(root, "site", DocKind::Md).unwrap(), Local::WrongType);
379 assert_eq!(
380 local(root, "../a.md", DocKind::Md).unwrap(),
381 Local::WrongType
382 );
383 }
384}