shackle-shell 0.4.1

A shell for restricting access on a version control server
Documentation
use crate::{
    parser::{GitReceivePackArgs, GitUploadPackArgs},
    user_info::{get_gid, get_user_groups, get_username},
    ShackleError,
};
use git2::{ErrorCode, Repository, RepositoryInitMode, RepositoryInitOptions};
use std::{
    fs,
    os::unix::fs::PermissionsExt,
    path::{Path, PathBuf},
    process::Command,
};

pub struct GitInitResult {
    pub path: PathBuf,
}

fn git_dir_prefix() -> PathBuf {
    PathBuf::from("git")
}

fn personal_git_dir() -> Result<PathBuf, ShackleError> {
    let username = get_username().ok_or(ShackleError::UserReadError)?;
    Ok(git_dir_prefix().join(username))
}

fn verify_user_is_in_group(group: &str) -> bool {
    let user_groups = get_user_groups();
    user_groups.iter().any(|g| g == group)
}

fn group_git_dir(group: &str) -> PathBuf {
    git_dir_prefix().join(group)
}

fn is_valid_git_repo_path(path: &Path) -> Result<bool, ShackleError> {
    let prefix = git_dir_prefix();
    let relative_path = match path.strip_prefix(&prefix) {
        Ok(relative_path) => relative_path,
        Err(_) => {
            return Ok(false);
        }
    };

    let mut it = relative_path.iter();
    let group = it.next();
    let repo_name = it.next();
    let end = it.next();

    match (group, repo_name, end) {
        (_, _, Some(_)) | (None, _, _) | (_, None, _) => Ok(false),
        (Some(group_name), Some(_repo_name), _) => {
            if relative_path.extension().map(|ext| ext == "git") != Some(true) {
                Ok(false)
            } else {
                let group_name = group_name.to_string_lossy();

                let user_name = get_username();
                let is_valid_personal_repo_path = user_name
                    .map(|user_name| user_name == group_name)
                    .unwrap_or(false);

                let user_groups = get_user_groups();
                let is_valid_shared_repo_path =
                    user_groups.iter().any(|group| group.as_str() == group_name);

                Ok(is_valid_personal_repo_path || is_valid_shared_repo_path)
            }
        }
    }
}

pub fn init(
    repo_name: &str,
    group: &Option<String>,
    description: &Option<String>,
    branch: &str,
    mirror: &Option<String>,
) -> Result<GitInitResult, ShackleError> {
    if let Some(group) = &group {
        if !verify_user_is_in_group(group) {
            return Err(ShackleError::InvalidGroup);
        }
    }

    let git_prefix = git_dir_prefix();
    let collection_dir = match group {
        Some(group) => group_git_dir(group),
        None => personal_git_dir()?,
    };
    let path = collection_dir.join(repo_name).with_extension("git");

    if !git_prefix.is_dir() {
        fs::create_dir(&git_prefix)?;
    }

    if !collection_dir.is_dir() {
        fs::create_dir(&collection_dir)?;

        if let Some(group) = group {
            let gid = get_gid(group).expect("User is in group but no group ID?");
            nix::unistd::chown(&collection_dir, None, Some(gid))?;
        }

        let mut perms = collection_dir.metadata()?.permissions();
        perms.set_mode(match group {
            Some(_) => 0o2770,
            None => 0o700,
        });
        fs::set_permissions(&collection_dir, perms)?;
    }

    let mut init_opts = RepositoryInitOptions::new();
    init_opts
        .bare(true)
        .mkdir(false)
        .no_reinit(true)
        .initial_head(branch);
    if group.is_some() {
        init_opts.mode(RepositoryInitMode::SHARED_GROUP);
    }

    let repo = Repository::init_opts(&path, &init_opts)?;

    if let Some(description) = description {
        // There is an init option for setting the description but it seems to
        // just do nothing?
        set_description(&path, description)?;
    }

    if let Some(mirror) = mirror {
        if description.is_none() {
            set_description(&path, &format!("Mirror of {}", mirror))?;
        }

        repo.remote_with_fetch("origin", mirror, "+refs/*:refs/*")?;
        let mut config = repo.config()?;
        config.set_bool("remote.origin.mirror", true)?;

        Command::new("git")
            .arg("remote")
            .arg("update")
            .arg("--prune")
            .current_dir(&path)
            .spawn()?
            .wait()?;
    }

    Ok(GitInitResult { path })
}

pub struct RepoMetadata {
    pub path: PathBuf,
    pub description: String,
}

pub struct VerboseRepoMetadata {
    pub path: PathBuf,
    pub description: String,
    pub size: u64,
}

fn get_size(path: impl AsRef<Path>) -> Result<u64, ShackleError> {
    let path_metadata = path.as_ref().symlink_metadata()?;

    if path_metadata.is_dir() {
        let mut size_in_bytes = path_metadata.len();
        for entry in path.as_ref().read_dir()? {
            let entry = entry?;
            let entry_metadata = entry.metadata()?;

            if entry_metadata.is_dir() {
                size_in_bytes += get_size(entry.path())?;
            } else {
                size_in_bytes += entry_metadata.len();
            }
        }
        Ok(size_in_bytes)
    } else {
        Ok(path_metadata.len())
    }
}

pub fn list() -> Result<Vec<RepoMetadata>, ShackleError> {
    fn add_from_dir(
        collection_dir: &Path,
        is_checking_group: bool,
    ) -> Result<Vec<RepoMetadata>, ShackleError> {
        let mut results = Vec::new();
        if !collection_dir.is_dir() {
            return Ok(results);
        }

        for dir in collection_dir.read_dir()? {
            let path = dir?.path();
            let description_path = path.join("description");
            let has_git_ext = path.extension().is_some_and(|ext| ext == "git");

            if has_git_ext {
                if let Ok(repo) = Repository::open_bare(&path) {
                    let config = repo.config()?.snapshot()?;
                    let shared_config = config.get_str("core.sharedRepository").or_else(|e| {
                        if e.code() == ErrorCode::NotFound {
                            Ok("")
                        } else {
                            Err(e)
                        }
                    })?;
                    let is_group_shared =
                        [Some("group"), Some("1"), Some("true")].contains(&Some(shared_config));

                    if is_group_shared == is_checking_group {
                        let description = if description_path.is_file() {
                            fs::read_to_string(description_path)?.trim().to_string()
                        } else {
                            String::new()
                        };

                        results.push(RepoMetadata { path, description });
                    }
                }
            }
        }
        Ok(results)
    }

    let mut results = Vec::new();

    results.append(&mut add_from_dir(&personal_git_dir()?, false)?);
    let groups = get_user_groups();
    for group in &groups {
        results.append(&mut add_from_dir(&group_git_dir(group), true)?);
    }

    results.sort_unstable_by_key(|r| r.path.clone());

    Ok(results)
}

pub fn list_verbose() -> Result<Vec<VerboseRepoMetadata>, ShackleError> {
    list()?
        .into_iter()
        .map(|meta| {
            get_size(&meta.path).map(|size| VerboseRepoMetadata {
                path: meta.path,
                description: meta.description,
                size,
            })
        })
        .collect()
}

pub fn set_description(directory: &Path, description: &str) -> Result<(), ShackleError> {
    if !is_valid_git_repo_path(directory)? {
        return Err(ShackleError::InvalidDirectory);
    }

    let description_path = directory.join("description");
    if description_path.is_file() {
        fs::write(description_path, description).map_err(|e| e.into())
    } else {
        Err(ShackleError::InvalidDirectory)
    }
}

pub fn set_branch(directory: &Path, branch: &str) -> Result<(), ShackleError> {
    if !is_valid_git_repo_path(directory)? {
        return Err(ShackleError::InvalidDirectory);
    }

    if let Ok(repo) = Repository::open_bare(directory) {
        repo.reference_symbolic(
            "HEAD",
            &format!("refs/heads/{branch}"),
            true,
            "shackle set-branch",
        )?;
        Ok(())
    } else {
        Err(ShackleError::InvalidDirectory)
    }
}

pub fn housekeeping(directory: &Path) -> Result<(), ShackleError> {
    if !is_valid_git_repo_path(directory)? {
        return Err(ShackleError::InvalidDirectory);
    }

    Command::new("git")
        .arg("gc")
        .arg("--prune=now")
        .current_dir(directory)
        .spawn()?
        .wait()?;

    Ok(())
}

pub fn delete(directory: &Path) -> Result<(), ShackleError> {
    if !is_valid_git_repo_path(directory)? {
        return Err(ShackleError::InvalidDirectory);
    }

    if Repository::open_bare(directory).is_ok() {
        fs::remove_dir_all(directory)?;
        Ok(())
    } else {
        Err(ShackleError::InvalidDirectory)
    }
}

pub fn upload_pack(upload_pack_args: &GitUploadPackArgs) -> Result<(), ShackleError> {
    if !is_valid_git_repo_path(&upload_pack_args.directory)? {
        return Err(ShackleError::InvalidDirectory);
    }

    let mut command = Command::new("git-upload-pack");
    command.arg("--strict");

    if let Some(timeout) = upload_pack_args.timeout {
        command.args(["--timeout", &timeout.to_string()]);
    }
    if upload_pack_args.stateless_rpc {
        command.arg("--stateless-rpc");
    }
    if upload_pack_args.advertise_refs {
        command.arg("--advertise-refs");
    }
    command.arg(&upload_pack_args.directory);

    command.spawn()?.wait()?;
    Ok(())
}

pub fn receive_pack(receive_pack_args: &GitReceivePackArgs) -> Result<(), ShackleError> {
    if !is_valid_git_repo_path(&receive_pack_args.directory)? {
        return Err(ShackleError::InvalidDirectory);
    }

    let mut command = Command::new("git-receive-pack");
    command.arg(&receive_pack_args.directory);

    command.spawn()?.wait()?;
    Ok(())
}