//! Wiki storage. A repo's wiki is a second bare repo (see [`crate::repo::wiki_path`]) whose `main` //! branch holds one `.md` file per page. Web edits are commits made with git plumbing: //! `hash-object`, a temporary index, `write-tree`, `commit-tree`, and a compare-and-swap //! `update-ref`. Two edits to the same page therefore never overwrite each other silently. use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use crate::browse::{EntryKind, default_branch_commit, read_text_blob, read_tree}; use crate::command::{GitCommand, GitEnv}; use crate::error::{Error, Result}; use crate::repo::init_bare; /// The branch that holds the pages. pub const WIKI_BRANCH: &str = "main"; /// Extension of a page file. pub const PAGE_EXT: &str = ".md"; /// Largest page the editor accepts, and the largest one read back. pub const MAX_PAGE_BYTES: usize = 256 * 1024; /// Longest page name, in characters. pub const MAX_PAGE_NAME: usize = 100; /// One page edit. `name` is the page name without the extension. #[derive(Debug, Clone, Copy)] pub struct PageEdit<'a> { pub name: &'a str, pub content: &'a [u8], pub message: &'a str, pub author_name: &'a str, pub author_email: &'a str, } /// True when `name` can name a page: 1 to 100 ASCII letters, digits, `-` or `_`, not starting with /// `-`. Dashes stand for spaces in titles, so a page name never has a space or a slash. pub fn valid_page_name(name: &str) -> bool { !name.is_empty() && name.len() <= MAX_PAGE_NAME && !name.starts_with('-') && name .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } /// The file that holds page `name` on the wiki branch. pub fn page_file(name: &str) -> String { format!("{name}{PAGE_EXT}") } /// Creates the wiki repo when it is missing. Calling it again is harmless. pub async fn ensure_wiki(env: &GitEnv, wiki: &Path) -> Result<()> { if wiki.join("HEAD").is_file() { return Ok(()); } init_bare(env, wiki, WIKI_BRANCH).await } /// The commit at the tip of the wiki's `main`. `None` when the wiki is missing or has no page yet. pub fn head_commit(wiki: &Path) -> Result> { if !wiki.join("HEAD").is_file() { return Ok(None); } default_branch_commit(wiki, WIKI_BRANCH) } /// Names of the pages at the head, sorted. Files that are not valid page names are skipped. pub fn list_pages(wiki: &Path) -> Result> { let Some(head) = head_commit(wiki)? else { return Ok(Vec::new()); }; let Some(entries) = read_tree(wiki, &head, "")? else { return Ok(Vec::new()); }; let mut names: Vec = entries .iter() .filter(|e| matches!(e.kind, EntryKind::Blob | EntryKind::Executable)) .filter_map(|e| e.name.strip_suffix(PAGE_EXT)) .filter(|name| valid_page_name(name)) .map(str::to_owned) .collect(); names.sort(); Ok(names) } /// The Markdown source of page `name` at the head. `None` when the name is invalid, the page does /// not exist, or it is not text. pub fn read_page(wiki: &Path, name: &str) -> Result> { if !valid_page_name(name) { return Ok(None); } let Some(head) = head_commit(wiki)? else { return Ok(None); }; read_text_blob(wiki, &head, &page_file(name), MAX_PAGE_BYTES) } /// One commit that touched a page. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PageRevision { pub oid: String, pub author: String, /// Unix seconds of the author date. pub at: i64, pub subject: String, } /// Most revisions `page_history` returns. pub const HISTORY_LIMIT: usize = 200; /// Commits that touched page `name`, newest first, at most [`HISTORY_LIMIT`]. pub async fn page_history(env: &GitEnv, wiki: &Path, name: &str) -> Result> { if !valid_page_name(name) || head_commit(wiki)?.is_none() { return Ok(Vec::new()); } let file = page_file(name); let out = GitCommand::new(env, "log") .git_dir(wiki) .arg("--format=%H%x1f%an%x1f%at%x1f%s") .arg("--max-count=200") .user_arg(&format!("refs/heads/{WIKI_BRANCH}"))? .paths(&[file.as_str()]) .output() .await?; let text = String::from_utf8_lossy(&out); Ok(text .lines() .filter_map(|line| { let mut parts = line.splitn(4, '\u{1f}'); Some(PageRevision { oid: parts.next()?.to_owned(), author: parts.next()?.to_owned(), at: parts.next()?.parse().ok()?, subject: parts.next()?.to_owned(), }) }) .collect()) } static INDEX_SEQ: AtomicU64 = AtomicU64::new(0); /// A temporary index file inside the wiki repo. Unique per process and call. fn temp_index(wiki: &Path) -> PathBuf { let seq = INDEX_SEQ.fetch_add(1, Ordering::Relaxed); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); wiki.join(format!("forge-index-{}-{nanos}-{seq}", std::process::id())) } /// Writes `edit` as a new commit on the wiki's `main`. `base` is the head the editor started from /// (`None` when the wiki had no commits). The edit lands only if the head is still `base`; otherwise /// it returns `Error::RefMoved` and writes nothing. Returns the new commit. pub async fn commit_page( env: &GitEnv, wiki: &Path, base: Option<&str>, edit: &PageEdit<'_>, ) -> Result { if !valid_page_name(edit.name) { return Err(Error::InvalidInput(format!( "not a valid page name: {:?}", edit.name ))); } if edit.content.len() > MAX_PAGE_BYTES { return Err(Error::InvalidInput("page is too large".into())); } ensure_wiki(env, wiki).await?; if head_commit(wiki)?.as_deref() != base { return Err(Error::RefMoved(format!( "wiki {WIKI_BRANCH} moved since the page was opened" ))); } let index = temp_index(wiki); let result = write_commit(env, wiki, &index, base, edit).await; // The index is scratch space. A leftover file is harmless, but do not keep it. let _ = tokio::fs::remove_file(&index).await; result } async fn write_commit( env: &GitEnv, wiki: &Path, index: &Path, base: Option<&str>, edit: &PageEdit<'_>, ) -> Result { let blob = GitCommand::new(env, "hash-object") .git_dir(wiki) .arg("-w") .arg("--stdin") .output_stdin(edit.content) .await?; let blob = String::from_utf8_lossy(&blob).trim().to_owned(); if let Some(base) = base { GitCommand::new(env, "read-tree") .git_dir(wiki) .env("GIT_INDEX_FILE", index) .user_arg(base)? .status() .await?; } let file = page_file(edit.name); GitCommand::new(env, "update-index") .git_dir(wiki) .env("GIT_INDEX_FILE", index) .arg("--add") .arg("--cacheinfo") .user_arg("100644")? .user_arg(&blob)? .user_arg(&file)? .status() .await?; let tree = GitCommand::new(env, "write-tree") .git_dir(wiki) .env("GIT_INDEX_FILE", index) .output() .await?; let tree = String::from_utf8_lossy(&tree).trim().to_owned(); let mut commit = GitCommand::new(env, "commit-tree") .config("commit.gpgsign=false") .git_dir(wiki) .env("GIT_AUTHOR_NAME", edit.author_name) .env("GIT_AUTHOR_EMAIL", edit.author_email) .env("GIT_COMMITTER_NAME", edit.author_name) .env("GIT_COMMITTER_EMAIL", edit.author_email) .user_arg(&tree)?; if let Some(base) = base { commit = commit.arg("-p").user_arg(base)?; } let commit = commit.arg("-F").arg("-"); let oid = commit.output_stdin(edit.message.as_bytes()).await?; let oid = String::from_utf8_lossy(&oid).trim().to_owned(); // Compare-and-swap: the ref must still hold `base`, or be absent when there was no base. let old = match base { Some(base) => base.to_owned(), None => "0".repeat(oid.len()), }; // Any failure here is treated as a lost race: the only expected cause is a concurrent edit. GitCommand::new(env, "update-ref") .git_dir(wiki) .arg("-m") .arg("forge wiki edit") .user_arg(&format!("refs/heads/{WIKI_BRANCH}"))? .user_arg(&oid)? .user_arg(&old)? .status() .await .map_err(|_| Error::RefMoved(format!("wiki {WIKI_BRANCH} moved during the edit")))?; Ok(oid) } #[cfg(test)] mod tests { use super::*; use crate::repo::{repo_path, wiki_path}; fn env(dir: &Path) -> GitEnv { GitEnv { git_bin: "/usr/bin/git".into(), data_dir: dir.to_path_buf(), } } fn edit<'a>(name: &'a str, body: &'a str) -> PageEdit<'a> { PageEdit { name, content: body.as_bytes(), message: "edit", author_name: "Alice", author_email: "alice@example.test", } } #[test] fn page_names_are_strict() { assert!(valid_page_name("Home")); assert!(valid_page_name("Getting-Started_2")); assert!(!valid_page_name("")); assert!(!valid_page_name("-x")); assert!(!valid_page_name("a b")); assert!(!valid_page_name("../etc")); assert!(!valid_page_name("a/b")); assert!(!valid_page_name(&"a".repeat(MAX_PAGE_NAME + 1))); } #[test] fn wiki_path_sits_next_to_the_repo() { let id = "01arz3ndektsv4rrffq69g5fav"; let main = repo_path(Path::new("/d"), id).unwrap(); let wiki = wiki_path(Path::new("/d"), id).unwrap(); assert_eq!( wiki.to_string_lossy(), format!("{}.wiki.git", main.with_extension("").display()) ); assert!(wiki_path(Path::new("/d"), "../x").is_err()); } #[tokio::test] async fn commit_then_read_list_and_history() { let dir = tempfile::tempdir().unwrap(); let env = env(dir.path()); let wiki = dir.path().join("w.wiki.git"); assert_eq!(head_commit(&wiki).unwrap(), None); assert!(list_pages(&wiki).unwrap().is_empty()); let first = commit_page(&env, &wiki, None, &edit("Home", "# Hi\n")) .await .unwrap(); assert_eq!(head_commit(&wiki).unwrap().as_deref(), Some(first.as_str())); assert_eq!(read_page(&wiki, "Home").unwrap().as_deref(), Some("# Hi\n")); let second = commit_page( &env, &wiki, Some(&first), &edit("Getting-Started", "more\n"), ) .await .unwrap(); assert_eq!( list_pages(&wiki).unwrap(), vec!["Getting-Started".to_owned(), "Home".to_owned()] ); assert_eq!(read_page(&wiki, "Home").unwrap().as_deref(), Some("# Hi\n")); let history = page_history(&env, &wiki, "Home").await.unwrap(); assert_eq!(history.len(), 1); assert_eq!(history[0].oid, first); assert_eq!(history[0].author, "Alice"); assert_eq!( page_history(&env, &wiki, "Getting-Started").await.unwrap()[0].oid, second ); } #[tokio::test] async fn stale_base_is_refused_without_writing() { let dir = tempfile::tempdir().unwrap(); let env = env(dir.path()); let wiki = dir.path().join("w.wiki.git"); let first = commit_page(&env, &wiki, None, &edit("Home", "one\n")) .await .unwrap(); commit_page(&env, &wiki, Some(&first), &edit("Home", "two\n")) .await .unwrap(); let err = commit_page(&env, &wiki, Some(&first), &edit("Home", "lost\n")) .await .unwrap_err(); assert!(matches!(err, Error::RefMoved(_)), "{err:?}"); assert_eq!(read_page(&wiki, "Home").unwrap().as_deref(), Some("two\n")); } #[tokio::test] async fn invalid_name_is_refused() { let dir = tempfile::tempdir().unwrap(); let env = env(dir.path()); let wiki = dir.path().join("w.wiki.git"); let err = commit_page(&env, &wiki, None, &edit("a/b", "x")) .await .unwrap_err(); assert!(matches!(err, Error::InvalidInput(_))); assert!(read_page(&wiki, "a/b").unwrap().is_none()); } }