//! Read-only access to the hosted repositories. //! //! A repository is public when its directory contains the //! `git-daemon-export-ok` marker file, the same convention git-daemon and //! git-http-backend follow. Everything else is invisible to the site. //! File contents always come from the default branch. use std::{ fmt, fs, path::{Path, PathBuf}, }; use anyhow::{Context, Error}; use git2::{ObjectType, Oid, Repository, Tree}; use jiff::Timestamp; /// Marker file whose presence makes a repository public. pub const EXPORT_MARKER: &str = "git-daemon-export-ok"; // git writes this placeholder into every new repository's description file. const DEFAULT_DESCRIPTION: &str = "Unnamed repository; edit this file"; // README files that are recognised, in order of preference. const README_NAMES: [&str; 4] = ["README.md", "README.markdown", "README", "README.txt"]; /// A validated repository name: the part before `.git` on disk and in /// URLs. Only plain names are accepted, so a name can never escape the /// store's directory. A name cannot itself end in `.git`: since the suffix /// is optional in URLs, such a name could never be told apart from the /// one it extends. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RepoName(String); impl RepoName { /// Parses a name as it appears in a URL, with or without a `.git` /// suffix. pub fn parse(text: &str) -> Result { let name = text.strip_suffix(".git").unwrap_or(text); let valid = !name.is_empty() && !name.starts_with('.') && !name.ends_with(".git") && !name.contains("..") && name .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')); if valid { Ok(RepoName(name.to_owned())) } else { Err(InvalidName(text.to_owned())) } } pub fn as_str(&self) -> &str { &self.0 } /// The directory name on disk. pub fn dir_name(&self) -> String { format!("{}.git", self.0) } } impl fmt::Display for RepoName { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } // Names render as plain text in templates. impl sailfish::runtime::Render for RepoName { fn render( &self, b: &mut sailfish::runtime::Buffer, ) -> Result<(), sailfish::runtime::RenderError> { self.0.render(b) } fn render_escaped( &self, b: &mut sailfish::runtime::Buffer, ) -> Result<(), sailfish::runtime::RenderError> { self.0.render_escaped(b) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct InvalidName(pub String); impl fmt::Display for InvalidName { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "invalid repository name: {:?}", self.0) } } impl std::error::Error for InvalidName {} /// A validated path inside a repository's tree: `/`-separated components, /// none of them empty, `.` or `..`. The empty path is the root tree. #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] pub struct RepoPath(Vec); impl RepoPath { pub fn root() -> Self { RepoPath(Vec::new()) } /// Parses a path as it appears in a URL. Leading, trailing and doubled /// slashes are tolerated; anything that could navigate is not. pub fn parse(text: &str) -> Result { let mut components = Vec::new(); for component in text.split('/') { match component { "" => {} "." | ".." => return Err(InvalidPath(text.to_owned())), c if c.contains('\0') => return Err(InvalidPath(text.to_owned())), c => components.push(c.to_owned()), } } Ok(RepoPath(components)) } pub fn components(&self) -> &[String] { &self.0 } pub fn is_root(&self) -> bool { self.0.is_empty() } pub fn file_name(&self) -> Option<&str> { self.0.last().map(String::as_str) } /// The directory containing this path; the root is its own parent. pub fn parent(&self) -> RepoPath { let mut parent = self.clone(); parent.0.pop(); parent } pub fn join(&self, component: &str) -> RepoPath { let mut joined = self.clone(); joined.0.push(component.to_owned()); joined } /// Every ancestor from the root down to this path, paired with its /// final component. For a breadcrumb trail. pub fn ancestry(&self) -> Vec<(String, RepoPath)> { let mut trail = Vec::new(); let mut current = RepoPath::root(); for component in &self.0 { current = current.join(component); trail.push((component.clone(), current.clone())); } trail } } impl fmt::Display for RepoPath { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0.join("/")) } } impl sailfish::runtime::Render for RepoPath { fn render( &self, b: &mut sailfish::runtime::Buffer, ) -> Result<(), sailfish::runtime::RenderError> { self.to_string().render(b) } fn render_escaped( &self, b: &mut sailfish::runtime::Buffer, ) -> Result<(), sailfish::runtime::RenderError> { self.to_string().render_escaped(b) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct InvalidPath(pub String); impl fmt::Display for InvalidPath { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "invalid repository path: {:?}", self.0) } } impl std::error::Error for InvalidPath {} /// What the site shows about a repository without opening its README. #[derive(Debug, Clone)] pub struct Summary { pub name: RepoName, pub description: Option, pub default_branch: Option, pub last_commit: Option, } impl Summary { /// The date of the most recent commit on the default branch, for /// display. pub fn last_commit_date(&self) -> Option { self.last_commit .map(|ts| ts.strftime("%Y-%m-%d").to_string()) } } /// A file on the default branch: where it is and how big it is. Its bytes /// are read separately, so that a page can decide whether it wants them. #[derive(Debug, Clone)] pub struct File { pub path: RepoPath, pub size: usize, id: Oid, } impl File { /// The object holding the file's bytes. pub fn id(&self) -> Oid { self.id } } /// One row of a directory listing. #[derive(Debug, Clone)] pub struct DirEntry { pub name: String, pub path: RepoPath, pub is_dir: bool, } /// Whatever lives at a path on the default branch. #[derive(Debug, Clone)] pub enum Entry { File(File), /// Directories first, then files, each group in name order. Dir(Vec), } /// The directory of bare repositories. #[derive(Debug, Clone)] pub struct Store { root: PathBuf, } impl Store { /// A store over `root`. The store is trusted by configuration, so /// libgit2's refusal to open repositories owned by another user is /// switched off: the repositories belong to the hosting user, not to /// the one serving them, and the allowlist libgit2 would consult lives /// in a global git config the service does not have. pub fn new(root: impl Into) -> Result { // SAFETY: a process-wide libgit2 option, set before any repository // is opened; setting it again later is idempotent. unsafe { git2::opts::set_verify_owner_validation(false) } .context("configuring libgit2 to trust the repository store")?; Ok(Store { root: root.into() }) } pub fn root(&self) -> &Path { &self.root } pub fn path(&self, name: &RepoName) -> PathBuf { self.root.join(name.dir_name()) } /// Whether a repository exists and is marked public. pub fn is_public(&self, name: &RepoName) -> bool { self.path(name).join(EXPORT_MARKER).is_file() } /// The names of every public repository, in case-insensitive /// alphabetical order. pub fn public_names(&self) -> Result, Error> { let mut names = Vec::new(); let entries = fs::read_dir(&self.root) .with_context(|| format!("reading repository directory {}", self.root.display()))?; for entry in entries { let entry = entry?; let Some(dir_name) = entry.file_name().to_str().map(str::to_owned) else { continue; }; if !dir_name.ends_with(".git") { continue; } // The whole directory name is parsed, so that `x.git.git` is // rejected rather than listed a second time as `x`. if let Ok(name) = RepoName::parse(&dir_name) && self.is_public(&name) { names.push(name); } } names.sort_by_cached_key(|name| (name.as_str().to_lowercase(), name.clone())); Ok(names) } /// Opens a public repository, or `None` when it does not exist or is /// private. pub fn open(&self, name: &RepoName) -> Result, Error> { if !self.is_public(name) { return Ok(None); } let path = self.path(name); let repo = Repository::open_bare(&path) .with_context(|| format!("opening repository {}", path.display()))?; Ok(Some(Repo { name: name.clone(), path, repo, })) } } /// A public repository that has been opened, for as many questions as one /// request has about it. pub struct Repo { name: RepoName, path: PathBuf, repo: Repository, } impl Repo { pub fn name(&self) -> &RepoName { &self.name } /// What the site shows about the repository without opening its README. pub fn summary(&self) -> Summary { let description = fs::read_to_string(self.path.join("description")) .ok() .map(|text| text.trim().to_owned()) .filter(|text| !text.is_empty() && !text.starts_with(DEFAULT_DESCRIPTION)); let head = self.repo.head().ok(); let default_branch = head .as_ref() .and_then(|head| head.shorthand().ok().map(str::to_owned)); let last_commit = head .and_then(|head| head.peel_to_commit().ok()) .and_then(|commit| Timestamp::from_second(commit.time().seconds()).ok()); Summary { name: self.name.clone(), description, default_branch, last_commit, } } /// The README at the root of the default branch, or `None` when there /// is none. Of several candidates, the most preferred name wins. pub fn readme(&self) -> Result, Error> { let Some(tree) = self.head_tree()? else { return Ok(None); }; let mut best: Option<(usize, String, Oid)> = None; for entry in tree.iter() { if entry.kind() != Some(ObjectType::Blob) { continue; } let Ok(name) = entry.name() else { continue; }; let Some(rank) = README_NAMES .iter() .position(|wanted| name.eq_ignore_ascii_case(wanted)) else { continue; }; if best .as_ref() .is_none_or(|(best_rank, ..)| rank < *best_rank) { best = Some((rank, name.to_owned(), entry.id())); } } best.map(|(_, name, id)| self.file(RepoPath::root().join(&name), id)) .transpose() } /// The file or directory at a path on the default branch, or `None` /// when there is nothing there. Submodules count as nothing: their /// contents are in another repository. pub fn entry(&self, path: &RepoPath) -> Result, Error> { let Some(root) = self.head_tree()? else { return Ok(None); }; let (id, kind) = if path.is_root() { (root.id(), Some(ObjectType::Tree)) } else { match root.get_path(Path::new(&path.to_string())) { Ok(entry) => (entry.id(), entry.kind()), Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(None), Err(err) => return Err(err.into()), } }; match kind { Some(ObjectType::Blob) => Ok(Some(Entry::File(self.file(path.clone(), id)?))), Some(ObjectType::Tree) => { let tree = self.repo.find_tree(id)?; let mut entries: Vec = tree .iter() .filter_map(|entry| { let is_dir = match entry.kind() { Some(ObjectType::Tree) => true, Some(ObjectType::Blob) => false, _ => return None, }; let name = entry.name().ok()?.to_owned(); Some(DirEntry { path: path.join(&name), name, is_dir, }) }) .collect(); entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name))); Ok(Some(Entry::Dir(entries))) } _ => Ok(None), } } /// The bytes of a file, whole; for the sizes a page shows. The raw /// view streams a file of any size through git instead. pub fn read(&self, file: &File) -> Result, Error> { Ok(self.repo.find_blob(file.id)?.content().to_vec()) } // The tree at HEAD, or None for an empty repository. fn head_tree(&self) -> Result>, Error> { let Some(commit) = self .repo .head() .ok() .and_then(|head| head.peel_to_commit().ok()) else { return Ok(None); }; Ok(Some(commit.tree()?)) } // A file's description, without reading its content: the object header // alone says how big it is. fn file(&self, path: RepoPath, id: Oid) -> Result { let (size, _) = self.repo.odb()?.read_header(id)?; Ok(File { path, size, id }) } } #[cfg(test)] mod tests { use assert2::check; use super::*; #[test] fn names_accept_plain_and_dot_git_forms() { check!(RepoName::parse("quickquill").unwrap().as_str() == "quickquill"); check!(RepoName::parse("quickquill.git").unwrap().as_str() == "quickquill"); check!(RepoName::parse("a.b_c-d").unwrap().dir_name() == "a.b_c-d.git"); } #[test] fn names_reject_anything_that_could_leave_the_store() { for bad in [ "", ".hidden", "..", "a..b", "a/b", "../x", "x y", ".git", "x.git.git", ] { check!(RepoName::parse(bad).is_err(), "{bad:?} should be rejected"); } } #[test] fn paths_normalise_slashes_and_reject_navigation() { check!(RepoPath::parse("").unwrap().is_root()); check!(RepoPath::parse("/").unwrap().is_root()); check!(RepoPath::parse("docs/").unwrap().to_string() == "docs"); check!(RepoPath::parse("/docs//guide.md").unwrap().to_string() == "docs/guide.md"); for bad in ["..", "docs/../x", "./x", "a/./b"] { check!(RepoPath::parse(bad).is_err(), "{bad:?} should be rejected"); } } #[test] fn path_ancestry_walks_from_the_root() { let path = RepoPath::parse("docs/img/logo.png").unwrap(); let trail: Vec = path .ancestry() .into_iter() .map(|(_, p)| p.to_string()) .collect(); check!(trail == ["docs", "docs/img", "docs/img/logo.png"]); check!(path.parent().to_string() == "docs/img"); check!(path.file_name() == Some("logo.png")); } }