use std::collections::BTreeMap;
use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use anyhow::Context;
use semora_proto::DocKind;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::project_link::{Link, shown};
pub const DIR: &str = ".semora-state";
const FILE: &str = "checkout.json";
const IGNORE: &str =
"# Semora's record of what this folder pulled and pushed. Not for committing.\n*\n";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Record {
pub id: String,
pub rev: i64,
pub kind: DocKind,
pub files: BTreeMap<String, String>,
}
pub type Records = BTreeMap<String, Record>;
#[derive(Debug, Default, Serialize, Deserialize)]
struct Stored {
server: String,
project: String,
#[serde(default)]
docs: Records,
}
pub fn sha256(data: &[u8]) -> String {
format!("{:x}", Sha256::digest(data))
}
fn file(link: &Link) -> PathBuf {
link.root.join(DIR).join(FILE)
}
fn read(link: &Link) -> anyhow::Result<Records> {
let path = file(link);
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Records::new()),
Err(e) => return Err(e).with_context(|| format!("reading {}", shown(&path))),
};
let stored: Stored = serde_json::from_str(&text).with_context(|| {
format!(
"{} is unreadable; delete it and run `semora pull` to start the record again",
shown(&path)
)
})?;
if stored.server != link.server || stored.project != link.slug {
return Ok(Records::new());
}
Ok(stored.docs)
}
pub fn load(link: &Link) -> anyhow::Result<Records> {
read(link)
}
pub fn update<T>(link: &Link, change: impl FnOnce(&mut Records) -> T) -> anyhow::Result<T> {
let dir = link.root.join(DIR);
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", shown(&dir)))?;
let ignore = dir.join(".gitignore");
if !ignore.exists() {
std::fs::write(&ignore, IGNORE).with_context(|| format!("writing {}", shown(&ignore)))?;
}
let lock_path = dir.join("lock");
let lock = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.with_context(|| format!("opening {}", shown(&lock_path)))?;
lock.lock()
.with_context(|| format!("locking {}", shown(&lock_path)))?;
let mut docs = read(link)?;
let out = change(&mut docs);
let stored = Stored {
server: link.server.clone(),
project: link.slug.clone(),
docs,
};
let text = serde_json::to_string_pretty(&stored).expect("records serialize");
let tmp = dir.join(format!("{FILE}.tmp"));
std::fs::write(&tmp, text).with_context(|| format!("writing {}", shown(&tmp)))?;
let path = file(link);
std::fs::rename(&tmp, &path).with_context(|| format!("writing {}", shown(&path)))?;
Ok(out)
}
pub fn local_rel(path: &str) -> Option<PathBuf> {
if path.is_empty()
|| path
.chars()
.any(|c| c.is_control() || c == '\\' || c == ':')
{
return None;
}
let mut out = PathBuf::new();
for seg in path.split('/') {
if seg.is_empty() || seg == "." || seg == ".." {
return None;
}
let lower = seg.to_ascii_lowercase();
if [".git", crate::project_link::FILE, DIR].contains(&lower.as_str()) {
return None;
}
if cfg!(windows) && !windows_name(seg) {
return None;
}
out.push(seg);
}
Some(out)
}
fn windows_name(seg: &str) -> bool {
const DEVICES: [&str; 22] = [
"con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
"com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
];
let stem = seg.split('.').next().unwrap_or("").to_ascii_lowercase();
!seg.contains(['<', '>', '"', '|', '?', '*'])
&& !seg.ends_with(['.', ' '])
&& !DEVICES.contains(&stem.as_str())
}
#[derive(Debug, PartialEq, Eq)]
pub enum Local {
Missing,
Files(BTreeMap<String, String>),
WrongType,
}
pub fn local(root: &Path, key: &str, kind: DocKind) -> anyhow::Result<Local> {
let Some(rel) = local_rel(key) else {
return Ok(Local::WrongType);
};
let full = root.join(rel);
let meta = match std::fs::metadata(&full) {
Ok(m) => m,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Local::Missing),
Err(e) if e.kind() == ErrorKind::NotADirectory => return Ok(Local::WrongType),
Err(e) => return Err(e).with_context(|| format!("reading {}", shown(&full))),
};
let mut files = BTreeMap::new();
match (kind, meta.is_dir()) {
(DocKind::Bundle, true) => {
let (list, _) = crate::bundle::list(&full)?;
for f in list {
let data = std::fs::read(full.join(&f))
.with_context(|| format!("reading {}", shown(&full.join(&f))))?;
files.insert(format!("{key}/{f}"), sha256(&data));
}
}
(DocKind::Bundle, false) | (DocKind::Md | DocKind::Html, true) => {
return Ok(Local::WrongType);
}
(DocKind::Md | DocKind::Html, false) => {
let data = std::fs::read(&full).with_context(|| format!("reading {}", shown(&full)))?;
files.insert(key.to_string(), sha256(&data));
}
}
Ok(Local::Files(files))
}
#[cfg(test)]
mod tests {
use super::*;
fn link(root: &Path) -> Link {
Link {
root: root.to_path_buf(),
server: "https://a.example.com".into(),
slug: "p".into(),
}
}
fn record(rev: i64) -> Record {
Record {
id: "k3F9xQ2mLp".into(),
rev,
kind: DocKind::Md,
files: [("docs/a.md".to_string(), sha256(b"# A"))].into(),
}
}
#[test]
fn records_round_trip_and_the_folder_ignores_itself() {
let d = tempfile::tempdir().unwrap();
let l = link(d.path());
assert!(load(&l).unwrap().is_empty());
update(&l, |r| r.insert("docs/a.md".into(), record(4))).unwrap();
assert_eq!(load(&l).unwrap()["docs/a.md"], record(4));
let ignore = std::fs::read_to_string(d.path().join(DIR).join(".gitignore")).unwrap();
assert!(ignore.lines().any(|l| l == "*"), "{ignore}");
}
#[test]
fn records_of_another_project_or_server_are_not_used() {
let d = tempfile::tempdir().unwrap();
let l = link(d.path());
update(&l, |r| r.insert("docs/a.md".into(), record(4))).unwrap();
let other = Link {
slug: "q".into(),
..l.clone()
};
assert!(load(&other).unwrap().is_empty());
let elsewhere = Link {
server: "https://b.example.com".into(),
..l.clone()
};
assert!(load(&elsewhere).unwrap().is_empty());
update(&other, |r| r.insert("x.md".into(), record(1))).unwrap();
assert!(
load(&l).unwrap().is_empty(),
"the file now holds q's records"
);
}
#[test]
fn an_unreadable_record_says_how_to_start_again() {
let d = tempfile::tempdir().unwrap();
std::fs::create_dir(d.path().join(DIR)).unwrap();
std::fs::write(d.path().join(DIR).join(FILE), "{not json").unwrap();
let err = format!("{:#}", load(&link(d.path())).unwrap_err());
assert!(err.contains("semora pull") && err.contains(FILE), "{err}");
}
#[test]
fn updates_at_the_same_time_keep_every_record() {
let d = tempfile::tempdir().unwrap();
let l = link(d.path());
let n = 16;
let start = std::sync::Barrier::new(n);
std::thread::scope(|s| {
for i in 0..n {
let (start, l) = (&start, &l);
s.spawn(move || {
start.wait();
update(l, |r| r.insert(format!("f{i}.md"), record(i as i64))).unwrap();
});
}
});
assert_eq!(load(&l).unwrap().len(), n);
}
#[test]
fn server_paths_stay_inside_the_project_folder() {
assert_eq!(
local_rel("docs/plan.md"),
Some(Path::new("docs").join("plan.md"))
);
assert!(
local_rel(".github/notes.md").is_some(),
"a hidden folder is fine"
);
for bad in [
"",
"/etc/passwd",
"../x.md",
"docs/../../x.md",
"docs//x.md",
"./x.md",
"a\\b.md",
"C:/x.md",
"x\n.md",
".git/hooks/x.md",
"sub/.GIT/config.md",
".semora",
".semora-state/checkout.json",
] {
assert_eq!(local_rel(bad), None, "{bad:?}");
}
}
#[test]
fn local_reads_a_file_or_a_folder_as_a_push_would() {
let d = tempfile::tempdir().unwrap();
let root = d.path();
assert_eq!(local(root, "a.md", DocKind::Md).unwrap(), Local::Missing);
std::fs::write(root.join("a.md"), "# A").unwrap();
assert_eq!(
local(root, "a.md", DocKind::Md).unwrap(),
Local::Files([("a.md".to_string(), sha256(b"# A"))].into())
);
assert_eq!(
local(root, "a.md", DocKind::Bundle).unwrap(),
Local::WrongType
);
std::fs::create_dir_all(root.join("site/.cache")).unwrap();
std::fs::write(root.join("site/index.md"), "# I").unwrap();
std::fs::write(root.join("site/.cache/x.md"), "hidden").unwrap();
std::fs::write(root.join("site/notes.txt"), "not served").unwrap();
assert_eq!(
local(root, "site", DocKind::Bundle).unwrap(),
Local::Files([("site/index.md".to_string(), sha256(b"# I"))].into()),
"only what a push would send"
);
assert_eq!(local(root, "site", DocKind::Md).unwrap(), Local::WrongType);
assert_eq!(
local(root, "../a.md", DocKind::Md).unwrap(),
Local::WrongType
);
}
}