forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
12.3 KiB
1//! Wiki storage. A repo's wiki is a second bare repo (see [`crate::repo::wiki_path`]) whose `main`
2//! branch holds one `<Page-Name>.md` file per page. Web edits are commits made with git plumbing:
3//! `hash-object`, a temporary index, `write-tree`, `commit-tree`, and a compare-and-swap
4//! `update-ref`. Two edits to the same page therefore never overwrite each other silently.
5
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use crate::browse::{EntryKind, default_branch_commit, read_text_blob, read_tree};
10use crate::command::{GitCommand, GitEnv};
11use crate::error::{Error, Result};
12use crate::repo::init_bare;
13
14/// The branch that holds the pages.
15pub const WIKI_BRANCH: &str = "main";
16/// Extension of a page file.
17pub const PAGE_EXT: &str = ".md";
18/// Largest page the editor accepts, and the largest one read back.
19pub const MAX_PAGE_BYTES: usize = 256 * 1024;
20/// Longest page name, in characters.
21pub const MAX_PAGE_NAME: usize = 100;
22
23/// One page edit. `name` is the page name without the extension.
24#[derive(Debug, Clone, Copy)]
25pub struct PageEdit<'a> {
26 pub name: &'a str,
27 pub content: &'a [u8],
28 pub message: &'a str,
29 pub author_name: &'a str,
30 pub author_email: &'a str,
31}
32
33/// True when `name` can name a page: 1 to 100 ASCII letters, digits, `-` or `_`, not starting with
34/// `-`. Dashes stand for spaces in titles, so a page name never has a space or a slash.
35pub fn valid_page_name(name: &str) -> bool {
36 !name.is_empty()
37 && name.len() <= MAX_PAGE_NAME
38 && !name.starts_with('-')
39 && name
40 .bytes()
41 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
42}
43
44/// The file that holds page `name` on the wiki branch.
45pub fn page_file(name: &str) -> String {
46 format!("{name}{PAGE_EXT}")
47}
48
49/// Creates the wiki repo when it is missing. Calling it again is harmless.
50pub async fn ensure_wiki(env: &GitEnv, wiki: &Path) -> Result<()> {
51 if wiki.join("HEAD").is_file() {
52 return Ok(());
53 }
54 init_bare(env, wiki, WIKI_BRANCH).await
55}
56
57/// The commit at the tip of the wiki's `main`. `None` when the wiki is missing or has no page yet.
58pub fn head_commit(wiki: &Path) -> Result<Option<String>> {
59 if !wiki.join("HEAD").is_file() {
60 return Ok(None);
61 }
62 default_branch_commit(wiki, WIKI_BRANCH)
63}
64
65/// Names of the pages at the head, sorted. Files that are not valid page names are skipped.
66pub fn list_pages(wiki: &Path) -> Result<Vec<String>> {
67 let Some(head) = head_commit(wiki)? else {
68 return Ok(Vec::new());
69 };
70 let Some(entries) = read_tree(wiki, &head, "")? else {
71 return Ok(Vec::new());
72 };
73 let mut names: Vec<String> = entries
74 .iter()
75 .filter(|e| matches!(e.kind, EntryKind::Blob | EntryKind::Executable))
76 .filter_map(|e| e.name.strip_suffix(PAGE_EXT))
77 .filter(|name| valid_page_name(name))
78 .map(str::to_owned)
79 .collect();
80 names.sort();
81 Ok(names)
82}
83
84/// The Markdown source of page `name` at the head. `None` when the name is invalid, the page does
85/// not exist, or it is not text.
86pub fn read_page(wiki: &Path, name: &str) -> Result<Option<String>> {
87 if !valid_page_name(name) {
88 return Ok(None);
89 }
90 let Some(head) = head_commit(wiki)? else {
91 return Ok(None);
92 };
93 read_text_blob(wiki, &head, &page_file(name), MAX_PAGE_BYTES)
94}
95
96/// One commit that touched a page.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct PageRevision {
99 pub oid: String,
100 pub author: String,
101 /// Unix seconds of the author date.
102 pub at: i64,
103 pub subject: String,
104}
105
106/// Most revisions `page_history` returns.
107pub const HISTORY_LIMIT: usize = 200;
108
109/// Commits that touched page `name`, newest first, at most [`HISTORY_LIMIT`].
110pub async fn page_history(env: &GitEnv, wiki: &Path, name: &str) -> Result<Vec<PageRevision>> {
111 if !valid_page_name(name) || head_commit(wiki)?.is_none() {
112 return Ok(Vec::new());
113 }
114 let file = page_file(name);
115 let out = GitCommand::new(env, "log")
116 .git_dir(wiki)
117 .arg("--format=%H%x1f%an%x1f%at%x1f%s")
118 .arg("--max-count=200")
119 .user_arg(&format!("refs/heads/{WIKI_BRANCH}"))?
120 .paths(&[file.as_str()])
121 .output()
122 .await?;
123 let text = String::from_utf8_lossy(&out);
124 Ok(text
125 .lines()
126 .filter_map(|line| {
127 let mut parts = line.splitn(4, '\u{1f}');
128 Some(PageRevision {
129 oid: parts.next()?.to_owned(),
130 author: parts.next()?.to_owned(),
131 at: parts.next()?.parse().ok()?,
132 subject: parts.next()?.to_owned(),
133 })
134 })
135 .collect())
136}
137
138static INDEX_SEQ: AtomicU64 = AtomicU64::new(0);
139
140/// A temporary index file inside the wiki repo. Unique per process and call.
141fn temp_index(wiki: &Path) -> PathBuf {
142 let seq = INDEX_SEQ.fetch_add(1, Ordering::Relaxed);
143 let nanos = std::time::SystemTime::now()
144 .duration_since(std::time::UNIX_EPOCH)
145 .map(|d| d.as_nanos())
146 .unwrap_or(0);
147 wiki.join(format!("forge-index-{}-{nanos}-{seq}", std::process::id()))
148}
149
150/// Writes `edit` as a new commit on the wiki's `main`. `base` is the head the editor started from
151/// (`None` when the wiki had no commits). The edit lands only if the head is still `base`; otherwise
152/// it returns `Error::RefMoved` and writes nothing. Returns the new commit.
153pub async fn commit_page(
154 env: &GitEnv,
155 wiki: &Path,
156 base: Option<&str>,
157 edit: &PageEdit<'_>,
158) -> Result<String> {
159 if !valid_page_name(edit.name) {
160 return Err(Error::InvalidInput(format!(
161 "not a valid page name: {:?}",
162 edit.name
163 )));
164 }
165 if edit.content.len() > MAX_PAGE_BYTES {
166 return Err(Error::InvalidInput("page is too large".into()));
167 }
168 ensure_wiki(env, wiki).await?;
169 if head_commit(wiki)?.as_deref() != base {
170 return Err(Error::RefMoved(format!(
171 "wiki {WIKI_BRANCH} moved since the page was opened"
172 )));
173 }
174 let index = temp_index(wiki);
175 let result = write_commit(env, wiki, &index, base, edit).await;
176 // The index is scratch space. A leftover file is harmless, but do not keep it.
177 let _ = tokio::fs::remove_file(&index).await;
178 result
179}
180
181async fn write_commit(
182 env: &GitEnv,
183 wiki: &Path,
184 index: &Path,
185 base: Option<&str>,
186 edit: &PageEdit<'_>,
187) -> Result<String> {
188 let blob = GitCommand::new(env, "hash-object")
189 .git_dir(wiki)
190 .arg("-w")
191 .arg("--stdin")
192 .output_stdin(edit.content)
193 .await?;
194 let blob = String::from_utf8_lossy(&blob).trim().to_owned();
195
196 if let Some(base) = base {
197 GitCommand::new(env, "read-tree")
198 .git_dir(wiki)
199 .env("GIT_INDEX_FILE", index)
200 .user_arg(base)?
201 .status()
202 .await?;
203 }
204 let file = page_file(edit.name);
205 GitCommand::new(env, "update-index")
206 .git_dir(wiki)
207 .env("GIT_INDEX_FILE", index)
208 .arg("--add")
209 .arg("--cacheinfo")
210 .user_arg("100644")?
211 .user_arg(&blob)?
212 .user_arg(&file)?
213 .status()
214 .await?;
215
216 let tree = GitCommand::new(env, "write-tree")
217 .git_dir(wiki)
218 .env("GIT_INDEX_FILE", index)
219 .output()
220 .await?;
221 let tree = String::from_utf8_lossy(&tree).trim().to_owned();
222
223 let mut commit = GitCommand::new(env, "commit-tree")
224 .config("commit.gpgsign=false")
225 .git_dir(wiki)
226 .env("GIT_AUTHOR_NAME", edit.author_name)
227 .env("GIT_AUTHOR_EMAIL", edit.author_email)
228 .env("GIT_COMMITTER_NAME", edit.author_name)
229 .env("GIT_COMMITTER_EMAIL", edit.author_email)
230 .user_arg(&tree)?;
231 if let Some(base) = base {
232 commit = commit.arg("-p").user_arg(base)?;
233 }
234 let commit = commit.arg("-F").arg("-");
235 let oid = commit.output_stdin(edit.message.as_bytes()).await?;
236 let oid = String::from_utf8_lossy(&oid).trim().to_owned();
237
238 // Compare-and-swap: the ref must still hold `base`, or be absent when there was no base.
239 let old = match base {
240 Some(base) => base.to_owned(),
241 None => "0".repeat(oid.len()),
242 };
243 // Any failure here is treated as a lost race: the only expected cause is a concurrent edit.
244 GitCommand::new(env, "update-ref")
245 .git_dir(wiki)
246 .arg("-m")
247 .arg("forge wiki edit")
248 .user_arg(&format!("refs/heads/{WIKI_BRANCH}"))?
249 .user_arg(&oid)?
250 .user_arg(&old)?
251 .status()
252 .await
253 .map_err(|_| Error::RefMoved(format!("wiki {WIKI_BRANCH} moved during the edit")))?;
254 Ok(oid)
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::repo::{repo_path, wiki_path};
261
262 fn env(dir: &Path) -> GitEnv {
263 GitEnv {
264 git_bin: "/usr/bin/git".into(),
265 data_dir: dir.to_path_buf(),
266 }
267 }
268
269 fn edit<'a>(name: &'a str, body: &'a str) -> PageEdit<'a> {
270 PageEdit {
271 name,
272 content: body.as_bytes(),
273 message: "edit",
274 author_name: "Alice",
275 author_email: "alice@example.test",
276 }
277 }
278
279 #[test]
280 fn page_names_are_strict() {
281 assert!(valid_page_name("Home"));
282 assert!(valid_page_name("Getting-Started_2"));
283 assert!(!valid_page_name(""));
284 assert!(!valid_page_name("-x"));
285 assert!(!valid_page_name("a b"));
286 assert!(!valid_page_name("../etc"));
287 assert!(!valid_page_name("a/b"));
288 assert!(!valid_page_name(&"a".repeat(MAX_PAGE_NAME + 1)));
289 }
290
291 #[test]
292 fn wiki_path_sits_next_to_the_repo() {
293 let id = "01arz3ndektsv4rrffq69g5fav";
294 let main = repo_path(Path::new("/d"), id).unwrap();
295 let wiki = wiki_path(Path::new("/d"), id).unwrap();
296 assert_eq!(
297 wiki.to_string_lossy(),
298 format!("{}.wiki.git", main.with_extension("").display())
299 );
300 assert!(wiki_path(Path::new("/d"), "../x").is_err());
301 }
302
303 #[tokio::test]
304 async fn commit_then_read_list_and_history() {
305 let dir = tempfile::tempdir().unwrap();
306 let env = env(dir.path());
307 let wiki = dir.path().join("w.wiki.git");
308 assert_eq!(head_commit(&wiki).unwrap(), None);
309 assert!(list_pages(&wiki).unwrap().is_empty());
310
311 let first = commit_page(&env, &wiki, None, &edit("Home", "# Hi\n"))
312 .await
313 .unwrap();
314 assert_eq!(head_commit(&wiki).unwrap().as_deref(), Some(first.as_str()));
315 assert_eq!(read_page(&wiki, "Home").unwrap().as_deref(), Some("# Hi\n"));
316
317 let second = commit_page(
318 &env,
319 &wiki,
320 Some(&first),
321 &edit("Getting-Started", "more\n"),
322 )
323 .await
324 .unwrap();
325 assert_eq!(
326 list_pages(&wiki).unwrap(),
327 vec!["Getting-Started".to_owned(), "Home".to_owned()]
328 );
329 assert_eq!(read_page(&wiki, "Home").unwrap().as_deref(), Some("# Hi\n"));
330
331 let history = page_history(&env, &wiki, "Home").await.unwrap();
332 assert_eq!(history.len(), 1);
333 assert_eq!(history[0].oid, first);
334 assert_eq!(history[0].author, "Alice");
335 assert_eq!(
336 page_history(&env, &wiki, "Getting-Started").await.unwrap()[0].oid,
337 second
338 );
339 }
340
341 #[tokio::test]
342 async fn stale_base_is_refused_without_writing() {
343 let dir = tempfile::tempdir().unwrap();
344 let env = env(dir.path());
345 let wiki = dir.path().join("w.wiki.git");
346 let first = commit_page(&env, &wiki, None, &edit("Home", "one\n"))
347 .await
348 .unwrap();
349 commit_page(&env, &wiki, Some(&first), &edit("Home", "two\n"))
350 .await
351 .unwrap();
352 let err = commit_page(&env, &wiki, Some(&first), &edit("Home", "lost\n"))
353 .await
354 .unwrap_err();
355 assert!(matches!(err, Error::RefMoved(_)), "{err:?}");
356 assert_eq!(read_page(&wiki, "Home").unwrap().as_deref(), Some("two\n"));
357 }
358
359 #[tokio::test]
360 async fn invalid_name_is_refused() {
361 let dir = tempfile::tempdir().unwrap();
362 let env = env(dir.path());
363 let wiki = dir.path().join("w.wiki.git");
364 let err = commit_page(&env, &wiki, None, &edit("a/b", "x"))
365 .await
366 .unwrap_err();
367 assert!(matches!(err, Error::InvalidInput(_)));
368 assert!(read_page(&wiki, "a/b").unwrap().is_none());
369 }
370}