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"));
    }
}