🏡 index : quine-global-labs/nugitea.git

//! Manages the on-disk layout of bare repositories: `<root>/<owner
//! path>/<name>.git`, one directory level per owner segment (so
//! `acme/platform/api` lives at `<root>/acme/platform/api.git`), mirroring
//! Gitea's `<root>/<owner>/<name>.git` extended to nested orgs. The
//! storage tier knows nothing about accounts — it just trusts that the
//! app tier already resolved a repo path against them.
//!
//! This module is storage-tier only — it's the one place that ever touches
//! local git paths. The app tier never sees a PathBuf, only repo names,
//! and talks to this tier over HTTP via `storage_client`.

use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};

use crate::gitcmd;
use crate::names;

/// Resolves repo names to on-disk paths under a root directory.
pub struct Store {
    pub root: PathBuf,
}

impl Store {
    /// Returns a Store rooted at root, creating the directory if needed.
    pub fn new(root: impl AsRef<Path>) -> Result<Self> {
        let root = root.as_ref();
        std::fs::create_dir_all(root).with_context(|| format!("create repo root {root:?}"))?;
        let root = root.canonicalize().with_context(|| format!("resolve repo root {root:?}"))?;
        Ok(Store { root })
    }

    /// Returns the absolute path to the bare repo directory for name
    /// (without requiring it to exist).
    pub fn path(&self, name: &str) -> Result<PathBuf> {
        names::validate_repo_path(name)?;
        Ok(self.root.join(format!("{name}.git")))
    }

    /// Reports whether the named repo exists on disk.
    pub async fn exists(&self, name: &str) -> bool {
        match self.path(name) {
            Ok(path) => tokio::fs::try_exists(&path).await.unwrap_or(false),
            Err(_) => false,
        }
    }

    /// Returns the paths of all repos under the root. Anything that isn't
    /// a valid repo path — e.g. a flat `<root>/<name>.git` left over from
    /// before repos had owners — is skipped.
    pub fn list(&self) -> Result<Vec<String>> {
        let mut names = Vec::new();
        list_under(&self.root, "", &mut names)?;
        names.retain(|n| names::validate_repo_path(n).is_ok());
        Ok(names)
    }

    /// Creates a new bare repository named `name` (git init --bare, then
    /// hook installation), mirroring Gitea's
    /// git.InitRepository + gitrepo.CreateDelegateHooks flow.
    pub async fn init_bare(&self, name: &str) -> Result<PathBuf> {
        let path = self.path(name)?;
        if path.exists() {
            bail!("repo {name:?} already exists");
        }
        tokio::fs::create_dir_all(&path).await?;

        gitcmd::run(&path, &["init", "--bare", "."]).await?;

        // The current binary at this point is always nugitea-storaged,
        // since hooks only ever run on the storage node.
        let storaged_bin = std::env::current_exe().context("resolve nugitea-storaged binary path")?;
        install_hooks(&path, &storaged_bin).await?;

        Ok(path)
    }
}

/// Walks owner directories under dir, collecting `<prefix><name>` for
/// each `<name>.git` found. Doesn't descend into repos themselves.
fn list_under(dir: &Path, prefix: &str, out: &mut Vec<String>) -> Result<()> {
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        if !entry.file_type()?.is_dir() {
            continue;
        }
        let Some(file_name) = entry.file_name().to_str().map(str::to_string) else { continue };
        match file_name.strip_suffix(".git") {
            Some(name) => out.push(format!("{prefix}{name}")),
            None => list_under(&entry.path(), &format!("{prefix}{file_name}/"), out)?,
        }
    }
    Ok(())
}

/// hook_names are the git server-side hooks nugitea installs a delegator
/// for. Each delegator just execs `nugitea-storaged hook <name>` and passes
/// stdin through, mirroring Gitea's hooks/<name> -> hooks/<name>.d/gitea
/// delegation (see modules/gitrepo/hooks.go) but flattened to a single
/// script since nugitea has no per-hook custom-script fan-out to support.
const HOOK_NAMES: [&str; 3] = ["pre-receive", "update", "post-receive"];

async fn install_hooks(repo_path: &Path, storaged_bin: &Path) -> Result<()> {
    let hooks_dir = repo_path.join("hooks");
    tokio::fs::create_dir_all(&hooks_dir).await?;

    let bin_quoted = shell_quote_single(&storaged_bin.to_string_lossy());
    for name in HOOK_NAMES {
        let script =
            format!("#!/bin/sh\n# AUTO GENERATED BY NUGITEA, DO NOT MODIFY\nexec {bin_quoted} hook {name} \"$@\"\n");
        let path = hooks_dir.join(name);
        tokio::fs::write(&path, script).await.with_context(|| format!("write hook {name}"))?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).await?;
        }
    }
    Ok(())
}

/// POSIX single-quote escaping, safe for embedding an arbitrary path into a
/// generated shell script.
fn shell_quote_single(s: &str) -> String {
    format!("'{}'", s.replace('\'', "'\\''"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn nested_layout() {
        let root = std::env::temp_dir().join(format!("nugitea-storage-test-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        let store = Store::new(&root).unwrap();
        assert_eq!(store.path("acme/platform/api").unwrap(), store.root.join("acme/platform/api.git"));
        assert!(store.path("../x").is_err());
        std::fs::create_dir_all(store.root.join("acme/platform/api.git")).unwrap();
        std::fs::create_dir_all(store.root.join("alice/dotfiles.git")).unwrap();
        std::fs::create_dir_all(store.root.join("legacy.git")).unwrap();
        let mut names = store.list().unwrap();
        names.sort();
        assert_eq!(names, ["acme/platform/api", "alice/dotfiles"]);
        assert!(store.exists("acme/platform/api").await);
        std::fs::remove_dir_all(&root).unwrap();
    }
}