michal/tit

Browse tree · Show commit · Download archive

Blob: src/diagnostics.rs

Raw · Blame

use std::collections::HashSet;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};

use thiserror::Error;

use crate::backup::{self, BackupError};
use crate::config::Config;
use crate::git::repository::{GitRepository, GitRepositoryError};
use crate::instance::REPOSITORY_DIRECTORY;
use crate::serve::{self, ServeError};
use crate::store::{
    AccountInspection, DATABASE_FILE, DumpRow, GitIntentInspection, RepositoryInspection, Store,
    StoreError,
};

const REQUIRED_INDEXES: &[&str] = &[
    "audit_event_correlation",
    "audit_event_history",
    "feed_token_account_active",
    "feed_token_repository_active",
    "git_operation_intent_incomplete",
    "issue_comment_history",
    "issue_repository_state",
    "login_nonce_active",
    "m1a_child_state_parent",
    "m1a_parent_created_at",
    "pull_request_ref_intent_incomplete",
    "pull_request_repository_state",
    "pull_request_review_status",
    "pull_request_review_timeline",
    "pull_request_revision_history",
    "repository_collaborator_account",
    "repository_event_feed",
    "repository_event_issue_timeline",
    "repository_event_pull_request_timeline",
    "signup_invitation_active",
    "ssh_public_key_account",
    "watch_account_activity",
    "web_session_account_active",
];

pub(crate) fn doctor(config: &Config, backups: &[PathBuf]) -> Result<(), DiagnosticError> {
    check_private_directory(&config.instance_dir)?;
    check_private_file(&config.config_path)?;
    let database = config.instance_dir.join(DATABASE_FILE);
    check_private_file(&database)?;
    crate::store::doctor(&config.instance_dir)?;

    let store = Store::open_read_only(&database)?;
    let indexes: HashSet<_> = store.index_names()?.into_iter().collect();
    for required in REQUIRED_INDEXES {
        if !indexes.contains(*required) {
            return Err(DiagnosticError::MissingIndex((*required).to_owned()));
        }
    }
    let git_intents = store.incomplete_git_intents()?;
    let pull_request_intents = store.incomplete_pull_request_ref_intents()?;
    if !git_intents.is_empty() || !pull_request_intents.is_empty() {
        return Err(DiagnosticError::IncompleteIntents {
            git: git_intents.len(),
            pull_request: pull_request_intents.len(),
        });
    }
    check_repositories(&config.instance_dir, &store)?;
    serve::check_host_key(&config.instance_dir)?;
    for archive in backups {
        backup::check_archive(archive)?;
    }
    Ok(())
}

pub(crate) fn inspect_account(
    config: &Config,
    username: &str,
) -> Result<AccountInspection, DiagnosticError> {
    let store = Store::open_read_only(&config.instance_dir.join(DATABASE_FILE))?;
    Ok(store.inspect_account(username)?)
}

pub(crate) fn inspect_repository(
    config: &Config,
    owner: &str,
    slug: &str,
) -> Result<RepositoryInspection, DiagnosticError> {
    let store = Store::open_read_only(&config.instance_dir.join(DATABASE_FILE))?;
    let repository = store.repository(owner, slug)?;
    let default_branch = store.repository_default_branch(owner, slug)?;
    check_repository(&config.instance_dir, &repository, &default_branch)?;
    Ok(repository.into())
}

pub(crate) fn inspect_intent(
    config: &Config,
    id: &str,
) -> Result<GitIntentInspection, DiagnosticError> {
    let store = Store::open_read_only(&config.instance_dir.join(DATABASE_FILE))?;
    Ok(store.inspect_git_intent(id)?)
}

pub(crate) fn dump(
    config: &Config,
    visit: impl FnMut(DumpRow) -> bool,
) -> Result<(), DiagnosticError> {
    let store = Store::open_read_only(&config.instance_dir.join(DATABASE_FILE))?;
    Ok(store.dump_rows(visit)?)
}

fn check_repositories(instance_dir: &Path, store: &Store) -> Result<(), DiagnosticError> {
    let root = instance_dir.join(REPOSITORY_DIRECTORY);
    check_private_directory(&root)?;
    let repositories = store.all_repositories()?;
    let expected: HashSet<_> = repositories
        .iter()
        .map(|repository| format!("{}.git", repository.id))
        .collect();
    for repository in &repositories {
        let default_branch =
            store.repository_default_branch(&repository.owner, &repository.slug)?;
        check_repository(instance_dir, repository, &default_branch)?;
    }
    for entry in fs::read_dir(&root).map_err(|source| DiagnosticError::Io {
        path: root.clone(),
        source,
    })? {
        let entry = entry.map_err(|source| DiagnosticError::Io {
            path: root.clone(),
            source,
        })?;
        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
            return Err(DiagnosticError::UnknownRepositoryEntry(entry.path()));
        };
        if !expected.contains(&name) {
            return Err(DiagnosticError::UnknownRepositoryEntry(entry.path()));
        }
    }
    Ok(())
}

fn check_repository(
    instance_dir: &Path,
    repository: &crate::store::RepositoryRecord,
    default_branch: &str,
) -> Result<(), DiagnosticError> {
    let path = instance_dir
        .join(REPOSITORY_DIRECTORY)
        .join(format!("{}.git", repository.id));
    let metadata = fs::symlink_metadata(&path).map_err(|source| DiagnosticError::Io {
        path: path.clone(),
        source,
    })?;
    if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
        return Err(DiagnosticError::UnsafePath(path));
    }
    let git = GitRepository::open(&path)?;
    let format = match git.object_format() {
        gix::hash::Kind::Sha1 => "sha1",
        gix::hash::Kind::Sha256 => "sha256",
        _ => return Err(DiagnosticError::UnsupportedObjectFormat),
    };
    if format != repository.object_format {
        return Err(DiagnosticError::ObjectFormat(repository.id.clone()));
    }
    if git.default_branch()?.as_deref() != Some(default_branch) {
        return Err(DiagnosticError::DefaultBranch(repository.id.clone()));
    }
    git.integrity_check()?;

    let quarantine = path.join("objects").join("tit-quarantine");
    match fs::symlink_metadata(&quarantine) {
        Ok(metadata) => {
            if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
                return Err(DiagnosticError::UnsafePath(quarantine));
            }
            if fs::read_dir(&quarantine)
                .map_err(|source| DiagnosticError::Io {
                    path: quarantine.clone(),
                    source,
                })?
                .next()
                .is_some()
            {
                return Err(DiagnosticError::QuarantineDebris(quarantine));
            }
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
        Err(source) => {
            return Err(DiagnosticError::Io {
                path: quarantine,
                source,
            });
        }
    }
    Ok(())
}

fn check_private_directory(path: &Path) -> Result<(), DiagnosticError> {
    let metadata = fs::symlink_metadata(path).map_err(|source| DiagnosticError::Io {
        path: path.to_owned(),
        source,
    })?;
    if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
        return Err(DiagnosticError::UnsafePath(path.to_owned()));
    }
    let mode = metadata.permissions().mode() & 0o777;
    if mode & 0o077 != 0 {
        return Err(DiagnosticError::Permissions {
            path: path.to_owned(),
            mode,
        });
    }
    Ok(())
}

fn check_private_file(path: &Path) -> Result<(), DiagnosticError> {
    let metadata = fs::symlink_metadata(path).map_err(|source| DiagnosticError::Io {
        path: path.to_owned(),
        source,
    })?;
    if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
        return Err(DiagnosticError::UnsafePath(path.to_owned()));
    }
    let mode = metadata.permissions().mode() & 0o777;
    if mode & 0o077 != 0 {
        return Err(DiagnosticError::Permissions {
            path: path.to_owned(),
            mode,
        });
    }
    Ok(())
}

#[derive(Debug, Error)]
pub(crate) enum DiagnosticError {
    #[error("diagnostic path is unsafe: {0}")]
    UnsafePath(PathBuf),
    #[error("diagnostic path permissions for {path} are {mode:o}, expected owner-only access")]
    Permissions { path: PathBuf, mode: u32 },
    #[error("required database index does not exist: {0}")]
    MissingIndex(String),
    #[error(
        "incomplete intents exist: {git} Git operation intents and {pull_request} pull-request ref intents"
    )]
    IncompleteIntents { git: usize, pull_request: usize },
    #[error("repository directory has an unknown entry: {0}")]
    UnknownRepositoryEntry(PathBuf),
    #[error("repository has quarantine debris: {0}")]
    QuarantineDebris(PathBuf),
    #[error("repository object format does not match its database record: {0}")]
    ObjectFormat(String),
    #[error("repository default branch does not match its database record: {0}")]
    DefaultBranch(String),
    #[error("repository object format is not supported")]
    UnsupportedObjectFormat,
    #[error("diagnostic I/O failed for {path}: {source}")]
    Io {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error(transparent)]
    Store(#[from] StoreError),
    #[error(transparent)]
    Git(#[from] GitRepositoryError),
    #[error(transparent)]
    HostKey(#[from] ServeError),
    #[error(transparent)]
    Backup(#[from] BackupError),
}