michal/tit
Browse tree · Show commit · Download archive
Blob: src/git/transport.rs
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
use tokio::sync::{AcquireError, OwnedSemaphorePermit, Semaphore};
const MAX_BLOCKING_GIT_JOBS: usize = 4;
#[derive(Clone)]
pub(crate) struct GitRepositories {
root: PathBuf,
push_database: Option<PathBuf>,
push_jobs: std::sync::Arc<Semaphore>,
blocking_jobs: std::sync::Arc<Semaphore>,
}
impl GitRepositories {
pub(crate) fn new(root: &Path) -> Result<Self, RepositoryPathError> {
let root = fs::canonicalize(root).map_err(|source| RepositoryPathError::Root {
path: root.to_owned(),
source,
})?;
Ok(Self {
root,
push_database: None,
push_jobs: std::sync::Arc::new(Semaphore::new(1)),
blocking_jobs: std::sync::Arc::new(Semaphore::new(MAX_BLOCKING_GIT_JOBS)),
})
}
pub(crate) fn new_with_pushes(
root: &Path,
database: &Path,
) -> Result<Self, RepositoryPathError> {
let mut repositories = Self::new(root)?;
repositories.push_database = Some(database.to_owned());
Ok(repositories)
}
pub(crate) fn resolve(
&self,
owner: &str,
repository: &str,
) -> Result<PathBuf, RepositoryPathError> {
if !valid_name(owner, 40) {
return Err(RepositoryPathError::InvalidName);
}
let repository = repository.strip_suffix(".git").unwrap_or(repository);
if !valid_name(repository, 100) {
return Err(RepositoryPathError::InvalidName);
}
let candidate = self.root.join(owner).join(format!("{repository}.git"));
let candidate =
fs::canonicalize(&candidate).map_err(|source| RepositoryPathError::Repository {
path: candidate,
source,
})?;
if !candidate.starts_with(&self.root) {
return Err(RepositoryPathError::OutsideRoot);
}
Ok(candidate)
}
pub(crate) fn resolve_ssh_command(
&self,
command: &[u8],
) -> Result<PathBuf, RepositoryPathError> {
let command =
std::str::from_utf8(command).map_err(|_| RepositoryPathError::InvalidCommand)?;
let path = command
.strip_prefix("git-upload-pack '")
.and_then(|value| value.strip_suffix('\''))
.ok_or(RepositoryPathError::InvalidCommand)?;
if path.contains('\'') {
return Err(RepositoryPathError::InvalidCommand);
}
let path = path.strip_prefix('/').unwrap_or(path);
let mut components = path.split('/');
let owner = components
.next()
.ok_or(RepositoryPathError::InvalidCommand)?;
let repository = components
.next()
.ok_or(RepositoryPathError::InvalidCommand)?;
if components.next().is_some() {
return Err(RepositoryPathError::InvalidCommand);
}
self.resolve(owner, repository)
}
pub(crate) fn resolve_ssh_service(
&self,
command: &[u8],
) -> Result<GitSshService, RepositoryPathError> {
if command.starts_with(b"git-upload-pack ") {
return self.resolve_ssh_command(command).map(GitSshService::Upload);
}
let command =
std::str::from_utf8(command).map_err(|_| RepositoryPathError::InvalidCommand)?;
let path = command
.strip_prefix("git-receive-pack '")
.and_then(|value| value.strip_suffix('\''))
.ok_or(RepositoryPathError::InvalidCommand)?;
if path.contains('\'') {
return Err(RepositoryPathError::InvalidCommand);
}
let path = path.strip_prefix('/').unwrap_or(path);
let mut components = path.split('/');
let owner = components
.next()
.ok_or(RepositoryPathError::InvalidCommand)?;
let repository = components
.next()
.ok_or(RepositoryPathError::InvalidCommand)?;
if components.next().is_some() {
return Err(RepositoryPathError::InvalidCommand);
}
self.resolve(owner, repository).map(GitSshService::Receive)
}
pub(crate) fn push_database(&self) -> Option<&Path> {
self.push_database.as_deref()
}
pub(crate) async fn push_permit(&self) -> Result<OwnedSemaphorePermit, AcquireError> {
self.push_jobs.clone().acquire_owned().await
}
pub(crate) async fn blocking_permit(&self) -> Result<OwnedSemaphorePermit, AcquireError> {
self.blocking_jobs.clone().acquire_owned().await
}
}
pub(crate) enum GitSshService {
Upload(PathBuf),
Receive(PathBuf),
}
fn valid_name(value: &str, maximum: usize) -> bool {
!value.is_empty()
&& value.len() <= maximum
&& value.is_ascii()
&& value.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)
})
&& !value.starts_with(['.', '-', '_'])
&& !value.ends_with(['.', '-', '_'])
&& !value.contains("..")
}
#[derive(Debug, Error)]
pub(crate) enum RepositoryPathError {
#[error("cannot open repository root {path}: {source}")]
Root {
path: PathBuf,
source: std::io::Error,
},
#[error("repository owner or name is not valid")]
InvalidName,
#[error("SSH Git command is not valid")]
InvalidCommand,
#[error("cannot open repository {path}: {source}")]
Repository {
path: PathBuf,
source: std::io::Error,
},
#[error("repository resolves outside the repository root")]
OutsideRoot,
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
#[test]
fn resolves_only_strict_repository_paths_and_commands() {
let directory = TempDir::new().expect("create a repository root");
let repository = directory.path().join("alice/example.git");
fs::create_dir_all(&repository).expect("create a repository");
let repositories =
GitRepositories::new(directory.path()).expect("open the repository root");
assert_eq!(
repositories
.resolve("alice", "example")
.expect("resolve a repository"),
fs::canonicalize(&repository).expect("canonicalize the repository")
);
assert!(matches!(
repositories
.resolve_ssh_service(b"git-receive-pack '/alice/example.git'")
.expect("resolve receive-pack"),
GitSshService::Receive(path) if path == fs::canonicalize(&repository).expect("canonicalize the repository")
));
assert_eq!(
repositories
.resolve_ssh_command(b"git-upload-pack '/alice/example.git'")
.expect("resolve an SSH Git command"),
fs::canonicalize(&repository).expect("canonicalize the repository")
);
for command in [
b"git-upload-pack '../../etc'".as_slice(),
b"git-upload-pack '/alice/example.git'; uname".as_slice(),
b"git-receive-pack '/alice/example.git'".as_slice(),
b"git-upload-pack '/alice/example.git/extra'".as_slice(),
] {
assert!(repositories.resolve_ssh_command(command).is_err());
}
}
}