//! What a repository is: the slug rules, the command a git client sends, the
//! directory the bare repository lives in, and who may reach it.
//!
//! Split from [`store`], which is the database side. This module is the rules
//! that hold either way - the pure shape of a repository address, and the
//! checks that keep a slug from climbing out of the repository root - so it is
//! where the unit tests live.
use std::path::{Path, PathBuf};
use std::process::Stdio;
use crate::entities::repositories::{MergePolicy, Visibility};
use crate::models::user::is_username;
pub mod git;
pub mod store;
/// The branch a new repository points `HEAD` at.
///
/// Pinned rather than inherited from the host's `init.defaultBranch`, which
/// git only defaults to `main` from 2.28 onward and otherwise still says
/// `master`.
pub const DEFAULT_BRANCH: &str = "main";
/// The longest a slug may be. It is one path segment and one URL segment, so
/// it has to stay readable as well as addressable.
pub const MAX_SLUG_LEN: usize = 64;
/// Names a slug may not take, because the webui answers something else at
/// `/~{user}/{slug}`.
///
/// A repository lives at `/~{user}/{slug}`, which is the same space the webui
/// puts its own pages in: `edit` edits the profile, `ssh-keys` manages the keys
/// that reach repositories, and `repositories` is where a new one is created. A
/// repository called any of those would be stored and pushed to over SSH, but
/// its page would be somebody else's form, and the clone URL on the profile
/// would point at the wrong place. So it cannot be created in the first place.
///
/// The ActivityPub endpoints also claim paths under `/~{user}/`: `inbox`,
/// `outbox`, `followers`, `notes`, and `activities`. A repository with any of
/// these names would collide with federation routes.
///
/// Every literal segment added under `/~{user}/` has to be added here too; the
/// routes that define them live in [`crate::webui`].
pub const RESERVED_SLUGS: &[&str] = &[
"edit",
"invites",
"repositories",
"ssh-keys",
"inbox",
"outbox",
"followers",
"notes",
"activities",
"discover",
"follow",
];
/// Whether `slug` is a name the webui already answers.
///
/// Matched exactly, because that is how the router matches too: `/~user/EDIT`
/// reaches a repository, so `EDIT` is not reserved. This is a rule about
/// creating, not about addressing - an existing repository with a reserved name
/// (from before this rule, or written straight to the database) is still
/// served over SSH, where there is no such collision.
pub fn is_reserved(slug: &str) -> bool {
RESERVED_SLUGS.contains(&slug)
}
/// Whether `slug` can name a repository.
///
/// Like a username it is a path segment and a URL segment, so it has to
/// survive both: no slash, no space, nothing a shell or a URL would eat. The
/// leading and trailing characters are restricted because a dot at the front
/// hides the directory and a dot at the end is silently dropped by some
/// filesystems.
pub fn is_slug(slug: &str) -> bool {
let length = slug.chars().count();
(1..=MAX_SLUG_LEN).contains(&length)
&& slug.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_')
})
&& !slug.starts_with('.')
&& !slug.ends_with('.')
// `.git` is the suffix the URL adds, so a slug ending in it would be
// reached as `name.git.git`.
&& !slug.to_ascii_lowercase().ends_with(".git")
}
/// What a git client asked the server to run over SSH.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitService {
/// `git-upload-pack`: clone, fetch, and `ls-remote`.
UploadPack,
/// `git-receive-pack`: push.
ReceivePack,
}
impl GitService {
/// The `git` subcommand that serves this service.
pub fn subcommand(self) -> &'static str {
match self {
GitService::UploadPack => "upload-pack",
GitService::ReceivePack => "receive-pack",
}
}
}
impl std::fmt::Display for GitService {
/// The command as a git client names it, so a log line reads like the
/// request it is about.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
GitService::UploadPack => "git-upload-pack",
GitService::ReceivePack => "git-receive-pack",
})
}
}
/// A repository addressed as `<owner>/<slug>`.
///
/// Both halves have been checked against their rules before one of these is
/// built, which is what lets [`RepositoryPath::bare_path`] join them onto a
/// root without worrying about `..`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepositoryPath {
pub owner: String,
pub slug: String,
}
impl RepositoryPath {
/// Parses the path half of a git command.
///
/// Git sends the path as it appeared in the URL, so it arrives as
/// `/owner/slug.git` for `ssh://` URLs and as `owner/slug.git` for the
/// `git@host:owner/slug.git` shorthand. Either shape resolves to the same
/// repository; a `~` before the owner is also accepted, the way
/// `ssh://host/~owner/repo` has always worked.
pub fn parse(path: &str) -> Option<Self> {
let path = path.strip_prefix('/').unwrap_or(path);
let path = path.strip_prefix('~').unwrap_or(path);
let (owner, slug) = path.split_once('/')?;
// A further slash would be a nested path, which is not how a
// repository is addressed here.
if slug.contains('/') {
return None;
}
let slug = slug.strip_suffix(".git")?;
if !is_username(owner) || !is_slug(slug) {
return None;
}
Some(RepositoryPath {
owner: owner.to_string(),
slug: slug.to_string(),
})
}
/// The directory the bare repository lives in, under `root`.
///
/// Safe to join because both halves were checked against their rules by
/// [`RepositoryPath::parse`].
pub fn bare_path(&self, root: &Path) -> PathBuf {
root.join(&self.owner).join(format!("{}.git", self.slug))
}
}
impl std::fmt::Display for RepositoryPath {
/// `owner/slug`, the way the repository is addressed everywhere but on
/// disk.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.owner, self.slug)
}
}
/// Why a command a git client sent cannot be honoured.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandError {
/// Not `git-upload-pack` or `git-receive-pack`.
Unsupported,
/// The command is one we run, but names no repository of ours.
Unusable,
}
/// Splits the command a git client runs over SSH into the service it wants and
/// the repository it names.
///
/// Git sends this as the whole of an SSH `exec` request - the entire command
/// line, quoted the way a shell would quote it. It is never handed to a shell
/// here, so the quoting is stripped deliberately rather than interpreted:
/// exactly one command word and exactly one path argument, nothing else.
pub fn parse_git_command(command: &str) -> Result<(GitService, RepositoryPath), CommandError> {
let mut parts = command.split_whitespace();
let service = match parts.next() {
Some("git-upload-pack") => GitService::UploadPack,
Some("git-receive-pack") => GitService::ReceivePack,
_ => return Err(CommandError::Unsupported),
};
let argument = parts.next().ok_or(CommandError::Unusable)?;
// Anything after the path is an argument we do not understand, so it is
// refused rather than guessed at.
if parts.next().is_some() {
return Err(CommandError::Unsupported);
}
let path = unquote(argument);
let path = RepositoryPath::parse(path).ok_or(CommandError::Unusable)?;
Ok((service, path))
}
/// Strips the single layer of quoting around `argument`.
///
/// Git quotes the path with single quotes because the command is nominally
/// passed to a shell, and that is the only shape it ever sends. Anything with
/// different quoting is left alone rather than repaired - the path rules reject
/// it, which is the point.
fn unquote(argument: &str) -> &str {
argument
.strip_prefix('\'')
.and_then(|rest| rest.strip_suffix('\''))
.unwrap_or(argument)
}
/// Whether `viewer` may read from a repository.
///
/// A public repository is readable by anyone at all, signed in or not, because
/// nothing here is served over SSH anonymously but a profile is public. A
/// private one is its owner's alone: `viewer` is the id of whoever is asking,
/// or `None` for somebody signed out.
pub fn may_read(visibility: Visibility, owner_id: i64, viewer: Option<i64>) -> bool {
match visibility {
Visibility::Public => true,
Visibility::Private => viewer == Some(owner_id),
}
}
/// Whether `viewer_id` may write to a repository.
///
/// Only its owner pushes. There are no collaborators yet.
pub fn may_write(owner_id: i64, viewer_id: i64) -> bool {
owner_id == viewer_id
}
/// The visibility a form selected.
///
/// Anything that is not exactly `private` reads as public, matching the
/// column's own default, so a missing or garbled field cannot silently land a
/// repository somewhere nobody can then reach it.
pub fn visibility_from_form(value: &str) -> Visibility {
match value {
"private" => Visibility::Private,
_ => Visibility::Public,
}
}
impl Visibility {
/// The word to show a reader for this visibility.
pub fn label(self) -> &'static str {
match self {
Visibility::Public => "public",
Visibility::Private => "private",
}
}
}
/// The merge policy a form selected.
///
/// Anything that does not match a known policy returns `None`, so an invalid
/// or missing value cannot silently change a repository's policy.
pub fn merge_policy_from_form(value: &str) -> Option<MergePolicy> {
match value {
"prefer-fast-forward" => Some(MergePolicy::PreferFastForward),
"fast-forward-only" => Some(MergePolicy::FastForwardOnly),
"merge-commit-only" => Some(MergePolicy::MergeCommitOnly),
"rebase-then-fast-forward" => Some(MergePolicy::RebaseThenFastForward),
_ => None,
}
}
impl MergePolicy {
/// The word to show a reader for this merge policy.
pub fn label(self) -> &'static str {
match self {
MergePolicy::PreferFastForward => "prefer-fast-forward",
MergePolicy::FastForwardOnly => "fast-forward-only",
MergePolicy::MergeCommitOnly => "merge-commit-only",
MergePolicy::RebaseThenFastForward => "rebase-then-fast-forward",
}
}
/// A human-readable description of this merge policy.
pub fn description(self) -> &'static str {
match self {
MergePolicy::PreferFastForward => "Prefer fast-forward",
MergePolicy::FastForwardOnly => "Fast-forward only",
MergePolicy::MergeCommitOnly => "Merge commit only",
MergePolicy::RebaseThenFastForward => "Rebase then fast-forward",
}
}
}
/// Creates the bare repository at `path`, with `HEAD` pointing at `main`.
///
/// `--bare` rather than a working tree: the repository is only ever read and
/// written by git itself, and a bare repository cannot have its working tree
/// and index committed to by accident.
#[tracing::instrument(level = "debug")]
pub async fn init_bare(path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let mut command = tokio::process::Command::new("git");
command
.arg("init")
.arg("--bare")
.arg(format!("--initial-branch={DEFAULT_BRANCH}"))
.arg(path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
let output = command.output().await?;
if output.status.success() {
return Ok(());
}
Err(std::io::Error::other(format!(
"git init --bare {} failed: {}",
path.display(),
String::from_utf8_lossy(&output.stderr).trim_end()
)))
}
#[cfg(test)]
mod tests {
use super::*;
fn path(owner: &str, slug: &str) -> RepositoryPath {
RepositoryPath {
owner: owner.to_string(),
slug: slug.to_string(),
}
}
#[test]
fn slug_rules() {
for slug in [
"notes",
"a",
"my-repo_2",
"notes.v2",
"a".repeat(64).as_str(),
] {
assert!(is_slug(slug), "{slug:?} should be a valid slug");
}
for slug in [
"",
"has space",
"has/slash",
".hidden",
"trailing.",
"notes.git",
"NOTES.GIT",
"a".repeat(65).as_str(),
"back\\slash",
] {
assert!(!is_slug(slug), "{slug:?} should be refused");
}
}
#[test]
fn reserved_names_are_slugs_but_not_available() {
// Every reserved name has to be a slug the rules would otherwise let
// through, or reserving it would be hiding a shape error instead.
for reserved in RESERVED_SLUGS {
assert!(
is_slug(reserved),
"{reserved:?} is reserved but would not be a valid slug anyway"
);
assert!(is_reserved(reserved), "{reserved:?} should be reserved");
}
// And the names around them are still free.
for free in [
"edit-notes",
"ssh_keys",
"edits",
"repositories2",
"EDIT",
"my-notes",
] {
assert!(!is_reserved(free), "{free:?} should not be reserved");
}
}
#[test]
fn path_parsing_accepts_both_shapes() {
// `ssh://host/owner/slug.git`
assert_eq!(
RepositoryPath::parse("/paul/notes.git"),
Some(path("paul", "notes"))
);
// `git@host:owner/slug.git`
assert_eq!(
RepositoryPath::parse("paul/notes.git"),
Some(path("paul", "notes"))
);
// `ssh://host/~owner/slug.git`
assert_eq!(
RepositoryPath::parse("/~paul/notes.git"),
Some(path("paul", "notes"))
);
}
#[test]
fn path_parsing_refuses_anything_else() {
for bad in [
// Not two components.
"",
"/",
"notes.git",
"/paul/",
// More than two components.
"/paul/team/notes.git",
// No `.git`.
"/paul/notes",
// A slug that would not be a slug.
"/paul/.hidden.git",
"/paul/notes.GIT.git",
// An owner that would not be a username.
"/pau l/notes.git",
"/../notes.git",
] {
assert!(
RepositoryPath::parse(bad).is_none(),
"{bad:?} should not parse as a repository path"
);
}
}
#[test]
fn a_traversal_cannot_be_addressed() {
// `..` is not a valid owner or slug, so it never reaches the
// filesystem as anything but a name.
assert_eq!(RepositoryPath::parse("/../notes.git"), None);
assert_eq!(RepositoryPath::parse("/paul/../notes.git"), None);
}
#[test]
fn command_parsing() {
let (service, repo) = parse_git_command("git-upload-pack '/paul/notes.git'")
.expect("a fetch command should parse");
assert_eq!(service, GitService::UploadPack);
assert_eq!(repo, path("paul", "notes"));
let (service, _) = parse_git_command("git-receive-pack 'paul/notes.git'")
.expect("a push command should parse");
assert_eq!(service, GitService::ReceivePack);
// Unquoted, the way some clients send it.
assert_eq!(
parse_git_command("git-upload-pack /paul/notes.git"),
Ok((GitService::UploadPack, path("paul", "notes")))
);
}
#[test]
fn command_parsing_refuses_anything_else() {
for bad in [
"",
"sh -c 'rm -rf /'",
"git-upload-pack",
"git-upload-pack a b",
"git-shell",
// Double-quoted instead, which git does not send but a client
// could.
"git-upload-pack \"/paul/notes.git\"",
// A path that names no repository of ours.
"git-upload-pack '/paul/../notes.git'",
"git-receive-pack '/paul/notes'",
] {
assert!(parse_git_command(bad).is_err(), "{bad:?} should be refused");
}
}
#[test]
fn bare_path_is_rooted_under_the_owner() {
let repo = path("paul", "notes");
assert_eq!(
repo.bare_path(Path::new("/repos")),
PathBuf::from("/repos/paul/notes.git")
);
}
#[test]
fn reading_is_public_but_writing_is_the_owners() {
let owner = 1;
let stranger = 2;
assert!(may_read(Visibility::Public, owner, Some(stranger)));
assert!(may_read(Visibility::Public, owner, None));
assert!(!may_read(Visibility::Private, owner, Some(stranger)));
assert!(!may_read(Visibility::Private, owner, None));
assert!(may_read(Visibility::Private, owner, Some(owner)));
assert!(may_write(owner, owner));
assert!(!may_write(owner, stranger));
}
}