repos.rs
raw
//! 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<Self, InvalidName> {
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<String>);
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<Self, InvalidPath> {
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<String>,
pub default_branch: Option<String>,
pub last_commit: Option<Timestamp>,
}
impl Summary {
/// The date of the most recent commit on the default branch, for
/// display.
pub fn last_commit_date(&self) -> Option<String> {
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<DirEntry>),
}
/// 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<PathBuf>) -> Result<Self, Error> {
// 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<Vec<RepoName>, 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<Option<Repo>, 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<Option<File>, 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<Option<Entry>, 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<DirEntry> = 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<Vec<u8>, 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<Option<Tree<'_>>, 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<File, Error> {
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<String> = 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"));
}
}