gix 0.49.0

Interact with git repositories just like git would
Documentation
#![allow(clippy::result_large_err)]
use std::{borrow::Cow, path::PathBuf};

use gix_features::threading::OwnShared;

use super::{Error, Options};
use crate::{
    config,
    config::{
        cache::{interpolate_context, util::ApplyLeniency},
        tree::{gitoxide, Core, Key, Safe},
    },
    open::Permissions,
    ThreadSafeRepository,
};

#[derive(Default, Clone)]
pub(crate) struct EnvironmentOverrides {
    /// An override of the worktree typically from the environment, and overrides even worktree dirs set as parameter.
    ///
    /// This emulates the way git handles this override.
    worktree_dir: Option<PathBuf>,
    /// An override for the .git directory, typically from the environment.
    ///
    /// If set, the passed in `git_dir` parameter will be ignored in favor of this one.
    git_dir: Option<PathBuf>,
}

impl EnvironmentOverrides {
    fn from_env() -> Result<Self, gix_sec::permission::Error<std::path::PathBuf>> {
        let mut worktree_dir = None;
        if let Some(path) = std::env::var_os(Core::WORKTREE.the_environment_override()) {
            worktree_dir = PathBuf::from(path).into();
        }
        let mut git_dir = None;
        if let Some(path) = std::env::var_os("GIT_DIR") {
            git_dir = PathBuf::from(path).into();
        }
        Ok(EnvironmentOverrides { worktree_dir, git_dir })
    }
}

impl ThreadSafeRepository {
    /// Open a git repository at the given `path`, possibly expanding it to `path/.git` if `path` is a work tree dir.
    pub fn open(path: impl Into<PathBuf>) -> Result<Self, Error> {
        Self::open_opts(path, Options::default())
    }

    /// Open a git repository at the given `path`, possibly expanding it to `path/.git` if `path` is a work tree dir, and use
    /// `options` for fine-grained control.
    ///
    /// Note that you should use [`crate::discover()`] if security should be adjusted by ownership.
    ///
    /// ### Differences to `git2::Repository::open_ext()`
    ///
    /// Whereas `open_ext()` is the jack-of-all-trades that can do anything depending on its options, `gix` will always differentiate
    /// between discovering git repositories by searching, and opening a well-known repository by work tree or `.git` repository.
    ///
    /// Note that opening a repository for implementing custom hooks is also handle specifically in
    /// [`open_with_environment_overrides()`][Self::open_with_environment_overrides()].
    pub fn open_opts(path: impl Into<PathBuf>, mut options: Options) -> Result<Self, Error> {
        let _span = gix_trace::coarse!("ThreadSafeRepository::open()");
        let (path, kind) = {
            let path = path.into();
            let looks_like_git_dir =
                path.ends_with(gix_discover::DOT_GIT_DIR) || path.extension() == Some(std::ffi::OsStr::new("git"));
            let candidate = if !options.open_path_as_is && !looks_like_git_dir {
                Cow::Owned(path.join(gix_discover::DOT_GIT_DIR))
            } else {
                Cow::Borrowed(&path)
            };
            match gix_discover::is_git(candidate.as_ref()) {
                Ok(kind) => (candidate.into_owned(), kind),
                Err(err) => {
                    if options.open_path_as_is || matches!(candidate, Cow::Borrowed(_)) {
                        return Err(Error::NotARepository {
                            source: err,
                            path: candidate.into_owned(),
                        });
                    }
                    match gix_discover::is_git(&path) {
                        Ok(kind) => (path, kind),
                        Err(err) => return Err(Error::NotARepository { source: err, path }),
                    }
                }
            }
        };
        let cwd = std::env::current_dir()?;
        let (git_dir, worktree_dir) = gix_discover::repository::Path::from_dot_git_dir(path, kind, &cwd)
            .expect("we have sanitized path with is_git()")
            .into_repository_and_work_tree_directories();
        if options.git_dir_trust.is_none() {
            options.git_dir_trust = gix_sec::Trust::from_path_ownership(&git_dir)?.into();
        }
        options.current_dir = Some(cwd);
        ThreadSafeRepository::open_from_paths(git_dir, worktree_dir, options)
    }

    /// Try to open a git repository in `fallback_directory` (can be worktree or `.git` directory) only if there is no override
    /// from of the `gitdir` using git environment variables.
    ///
    /// Use the `trust_map` to apply options depending in the trust level for `directory` or the directory it's overridden with.
    /// The `.git` directory whether given or computed is used for trust checks.
    ///
    /// Note that this will read various `GIT_*` environment variables to check for overrides, and is probably most useful when implementing
    /// custom hooks.
    // TODO: tests, with hooks, GIT_QUARANTINE for ref-log and transaction control (needs gix-sec support to remove write access in gix-ref)
    // TODO: The following vars should end up as overrides of the respective configuration values (see gix-config).
    //       GIT_PROXY_SSL_CERT, GIT_PROXY_SSL_KEY, GIT_PROXY_SSL_CERT_PASSWORD_PROTECTED.
    //       GIT_PROXY_SSL_CAINFO, GIT_SSL_CIPHER_LIST, GIT_HTTP_MAX_REQUESTS, GIT_CURL_FTP_NO_EPSV,
    pub fn open_with_environment_overrides(
        fallback_directory: impl Into<PathBuf>,
        trust_map: gix_sec::trust::Mapping<Options>,
    ) -> Result<Self, Error> {
        let _span = gix_trace::coarse!("ThreadSafeRepository::open_with_environment_overrides()");
        let overrides = EnvironmentOverrides::from_env()?;
        let (path, path_kind): (PathBuf, _) = match overrides.git_dir {
            Some(git_dir) => gix_discover::is_git(&git_dir)
                .map_err(|err| Error::NotARepository {
                    source: err,
                    path: git_dir.clone(),
                })
                .map(|kind| (git_dir, kind))?,
            None => {
                let fallback_directory = fallback_directory.into();
                gix_discover::is_git(&fallback_directory)
                    .map_err(|err| Error::NotARepository {
                        source: err,
                        path: fallback_directory.clone(),
                    })
                    .map(|kind| (fallback_directory, kind))?
            }
        };

        let cwd = std::env::current_dir()?;
        let (git_dir, worktree_dir) = gix_discover::repository::Path::from_dot_git_dir(path, path_kind, &cwd)
            .expect("we have sanitized path with is_git()")
            .into_repository_and_work_tree_directories();
        let worktree_dir = worktree_dir.or(overrides.worktree_dir);

        let git_dir_trust = gix_sec::Trust::from_path_ownership(&git_dir)?;
        let mut options = trust_map.into_value_by_level(git_dir_trust);
        options.current_dir = Some(cwd);
        ThreadSafeRepository::open_from_paths(git_dir, worktree_dir, options)
    }

    pub(crate) fn open_from_paths(
        git_dir: PathBuf,
        mut worktree_dir: Option<PathBuf>,
        options: Options,
    ) -> Result<Self, Error> {
        let _span = gix_trace::detail!("open_from_paths()");
        let Options {
            git_dir_trust,
            object_store_slots,
            filter_config_section,
            lossy_config,
            lenient_config,
            bail_if_untrusted,
            open_path_as_is: _,
            permissions:
                Permissions {
                    ref env,
                    config,
                    attributes,
                },
            ref api_config_overrides,
            ref cli_config_overrides,
            ref current_dir,
        } = options;
        let current_dir = current_dir.as_deref().expect("BUG: current_dir must be set by caller");
        let git_dir_trust = git_dir_trust.expect("trust must be determined by now");

        // TODO: assure we handle the worktree-dir properly as we can have config per worktree with an extension.
        //       This would be something read in later as have to first check for extensions. Also this means
        //       that each worktree, even if accessible through this instance, has to come in its own Repository instance
        //       as it may have its own configuration. That's fine actually.
        let common_dir = gix_discover::path::from_plain_file(git_dir.join("commondir"))
            .transpose()?
            .map(|cd| git_dir.join(cd));
        let common_dir_ref = common_dir.as_deref().unwrap_or(&git_dir);

        let repo_config = config::cache::StageOne::new(
            common_dir_ref,
            git_dir.as_ref(),
            git_dir_trust,
            lossy_config,
            lenient_config,
        )?;
        let mut refs = {
            let reflog = repo_config.reflog.unwrap_or(gix_ref::store::WriteReflog::Disable);
            let object_hash = repo_config.object_hash;
            match &common_dir {
                Some(common_dir) => crate::RefStore::for_linked_worktree(&git_dir, common_dir, reflog, object_hash),
                None => crate::RefStore::at(&git_dir, reflog, object_hash),
            }
        };
        let head = refs.find("HEAD").ok();
        let git_install_dir = crate::path::install_dir().ok();
        let home = gix_path::env::home_dir().and_then(|home| env.home.check_opt(home));

        let mut filter_config_section = filter_config_section.unwrap_or(config::section::is_trusted);
        let config = config::Cache::from_stage_one(
            repo_config,
            common_dir_ref,
            head.as_ref().and_then(|head| head.target.try_name()),
            filter_config_section,
            git_install_dir.as_deref(),
            home.as_deref(),
            *env,
            attributes,
            config,
            lenient_config,
            api_config_overrides,
            cli_config_overrides,
        )?;

        if bail_if_untrusted && git_dir_trust != gix_sec::Trust::Full {
            check_safe_directories(&git_dir, git_install_dir.as_deref(), home.as_deref(), &config)?;
        }

        // core.worktree might be used to overwrite the worktree directory
        if !config.is_bare {
            if let Some(wt) = config
                .resolved
                .path_filter("core", None, Core::WORKTREE.name, &mut filter_config_section)
            {
                let wt_path = wt
                    .interpolate(interpolate_context(git_install_dir.as_deref(), home.as_deref()))
                    .map_err(config::Error::PathInterpolation)?;
                worktree_dir = {
                    gix_path::normalize(git_dir.join(wt_path), current_dir)
                        .and_then(|wt| wt.as_ref().is_dir().then(|| wt.into_owned()))
                }
            }
        }

        match worktree_dir {
            None if !config.is_bare => {
                worktree_dir = Some(git_dir.parent().expect("parent is always available").to_owned());
            }
            Some(_) => {
                // note that we might be bare even with a worktree directory - work trees don't have to be
                // the parent of a non-bare repository.
            }
            None => {}
        }

        refs.write_reflog = config::cache::util::reflog_or_default(config.reflog, worktree_dir.is_some());
        let replacements = replacement_objects_refs_prefix(&config.resolved, lenient_config, filter_config_section)?
            .and_then(|prefix| {
                let _span = gix_trace::detail!("find replacement objects");
                let platform = refs.iter().ok()?;
                let iter = platform.prefixed(&prefix).ok()?;
                let prefix = prefix.to_str()?;
                let replacements = iter
                    .filter_map(Result::ok)
                    .filter_map(|r: gix_ref::Reference| {
                        let target = r.target.try_id()?.to_owned();
                        let source =
                            gix_hash::ObjectId::from_hex(r.name.as_bstr().strip_prefix(prefix.as_bytes())?).ok()?;
                        Some((source, target))
                    })
                    .collect::<Vec<_>>();
                Some(replacements)
            })
            .unwrap_or_default();

        Ok(ThreadSafeRepository {
            objects: OwnShared::new(gix_odb::Store::at_opts(
                common_dir_ref.join("objects"),
                replacements,
                gix_odb::store::init::Options {
                    slots: object_store_slots,
                    object_hash: config.object_hash,
                    use_multi_pack_index: config.use_multi_pack_index,
                    current_dir: current_dir.to_owned().into(),
                },
            )?),
            common_dir,
            refs,
            work_tree: worktree_dir,
            config,
            // used when spawning new repositories off this one when following worktrees
            linked_worktree_options: options,
            index: gix_fs::SharedFileSnapshotMut::new().into(),
            shallow_commits: gix_fs::SharedFileSnapshotMut::new().into(),
        })
    }
}

// TODO: tests
fn replacement_objects_refs_prefix(
    config: &gix_config::File<'static>,
    lenient: bool,
    mut filter_config_section: fn(&gix_config::file::Metadata) -> bool,
) -> Result<Option<PathBuf>, Error> {
    let is_disabled = config
        .boolean_filter_by_key("core.useReplaceRefs", &mut filter_config_section)
        .map(|b| Core::USE_REPLACE_REFS.enrich_error(b))
        .transpose()
        .with_leniency(lenient)
        .map_err(config::Error::ConfigBoolean)?
        .unwrap_or(true);

    if is_disabled {
        return Ok(None);
    }

    let ref_base = gix_path::from_bstr({
        let key = "gitoxide.objects.replaceRefBase";
        debug_assert_eq!(gitoxide::Objects::REPLACE_REF_BASE.logical_name(), key);
        config
            .string_filter_by_key(key, &mut filter_config_section)
            .unwrap_or_else(|| Cow::Borrowed("refs/replace/".into()))
    })
    .into_owned();
    Ok(ref_base.into())
}

fn check_safe_directories(
    git_dir: &std::path::Path,
    git_install_dir: Option<&std::path::Path>,
    home: Option<&std::path::Path>,
    config: &config::Cache,
) -> Result<(), Error> {
    let mut is_safe = false;
    let git_dir = match gix_path::realpath(git_dir) {
        Ok(p) => p,
        Err(_) => git_dir.to_owned(),
    };
    for safe_dir in config
        .resolved
        .strings_filter("safe", None, Safe::DIRECTORY.name, &mut Safe::directory_filter)
        .unwrap_or_default()
    {
        if safe_dir.as_ref() == "*" {
            is_safe = true;
            continue;
        }
        if safe_dir.is_empty() {
            is_safe = false;
            continue;
        }
        if !is_safe {
            let safe_dir = match gix_config::Path::from(std::borrow::Cow::Borrowed(safe_dir.as_ref()))
                .interpolate(interpolate_context(git_install_dir, home))
            {
                Ok(path) => path,
                Err(_) => gix_path::from_bstr(safe_dir),
            };
            if safe_dir == git_dir {
                is_safe = true;
                continue;
            }
        }
    }
    if is_safe {
        Ok(())
    } else {
        Err(Error::UnsafeGitDir { path: git_dir })
    }
}