use std::{borrow::Cow, env, path::PathBuf}; use bstr::{ByteSlice, ByteVec}; /// The error returned by [git_discover::discover()][function::discover()].#[derive(Debug, thiserror::Error)]#[allow(missing_docs)]pub enum Error { #[error("Failed to access a directory, or path is not a directory: '{}'", .path.display())] InaccessibleDirectory { path: PathBuf }, #[error("Could find a git repository in '{}' or in any of its parents", .path.display())] NoGitRepository { path: PathBuf }, #[error("Could find a git repository in '{}' or in any of its parents within ceiling height of {}", .path.display(), .ceiling_height)] NoGitRepositoryWithinCeiling { path: PathBuf, ceiling_height: usize }, #[error("Could find a git repository in '{}' or in any of its parents within device limits below '{}'", .path.display(), .limit.display())] NoGitRepositoryWithinFs { path: PathBuf, limit: PathBuf }, #[error("None of the passed ceiling directories prefixed the git-dir candidate, making them ineffective.")] NoMatchingCeilingDir, #[error("Could find a trusted git repository in '{}' or in any of its parents, candidate at '{}' discarded", .path.display(), .candidate.display())] NoTrustedGitRepository { path: PathBuf, candidate: PathBuf, required: git_sec::Trust, }, #[error("Could not determine trust level for path '{}'.", .path.display())] CheckTrust { path: PathBuf, #[source] err: std::io::Error, },} /// Options to help guide the [discovery][function::discover()] of repositories, along with their options/// when instantiated.pub struct Options { /// When discovering a repository, assure it has at least this trust level or ignore it otherwise. /// /// This defaults to [`Reduced`][git_sec::Trust::Reduced] as our default settings are geared towards avoiding abuse. /// Set it to `Full` to only see repositories that [are owned by the current user][git_sec::Trust::from_path_ownership()]. pub required_trust: git_sec::Trust, /// When discovering a repository, ignore any repositories that are located in these directories or any of their parents. pub ceiling_dirs: Vec<PathBuf>, /// If true, and `ceiling_dirs` is not empty, we expect at least one ceiling directory to match or else there will be an error. pub match_ceiling_dir_or_error: bool, /// if `true` avoid crossing filesystem boundaries. /// Only supported on Unix-like systems. // TODO: test on Linux // TODO: Handle WASI once https://github.com/rust-lang/rust/issues/71213 is resolved pub cross_fs: bool,} impl Default for Options { fn default() -> Self { Options { required_trust: git_sec::Trust::Reduced, ceiling_dirs: vec![], match_ceiling_dir_or_error: true, cross_fs: false, } }} impl Options { /// Loads discovery options overrides from the environment. /// /// The environment variables are: /// - `GIT_CEILING_DIRECTORIES` for `ceiling_dirs` /// /// Note that `GIT_DISCOVERY_ACROSS_FILESYSTEM` for `cross_fs` is **not** read, /// as it requires parsing of `git-config` style boolean values. // TODO: test pub fn apply_environment(mut self) -> Self { let name = "GIT_CEILING_DIRECTORIES"; if let Some(ceiling_dirs) = env::var_os(name).and_then(|c| Vec::from_os_string(c).ok()) { self.ceiling_dirs = parse_ceiling_dirs(&ceiling_dirs); } self }} /// Parse a byte-string of `:`-separated paths into `Vec<PathBuf>`./// Non-absolute paths are discarded./// To match git, all paths are normalized, until an empty path is encountered.fn parse_ceiling_dirs(ceiling_dirs: &[u8]) -> Vec<PathBuf> { let mut should_normalize = true; let mut result = Vec::new(); for ceiling_dir in ceiling_dirs.split_str(":") { if ceiling_dir.is_empty() { should_normalize = false; continue; } // Paths that are invalid unicode can't be handled let mut dir = match ceiling_dir.to_path() { Ok(dir) => Cow::Borrowed(dir), Err(_) => continue, }; // Only absolute paths are allowed if dir.is_relative() { continue; } if should_normalize { if let Ok(normalized) = git_path::realpath(&dir) { dir = Cow::Owned(normalized); } } result.push(dir.into_owned()); } result} #[cfg(test)]mod parse_ceiling_dirs { use super::*; #[test] #[cfg(unix)] fn from_environment_format() -> std::io::Result<()> { use std::{fs, os::unix::fs::symlink}; // Setup filesystem let dir = tempfile::tempdir().expect("success creating temp dir"); let direct_path = dir.path().join("direct"); let symlink_path = dir.path().join("symlink"); fs::create_dir(&direct_path)?; symlink(&direct_path, &symlink_path)?; // Parse & build ceiling dirs string let symlink_str = symlink_path.to_str().expect("symlink path is valid utf8"); let ceiling_dir_string = format!("{}:relative::{}", symlink_str, symlink_str); let ceiling_dirs = parse_ceiling_dirs(ceiling_dir_string.as_bytes()); assert_eq!(ceiling_dirs.len(), 2, "Relative path is discarded"); assert_eq!( ceiling_dirs[0], symlink_path.canonicalize().expect("symlink path exists"), "Symlinks are resolved" ); assert_eq!( ceiling_dirs[1], symlink_path, "Symlink are not resolved after empty item" ); dir.close() }} pub(crate) mod function { #[cfg(unix)] use std::fs; use std::path::{Path, PathBuf}; use git_sec::Trust; use super::{Error, Options}; use crate::{is_git, DOT_GIT_DIR}; /// Find the location of the git repository directly in `directory` or in any of its parent directories and provide /// an associated Trust level by looking at the git directory's ownership, and control discovery using `options`. /// /// Fail if no valid-looking git repository could be found. // TODO: tests for trust-based discovery #[cfg_attr(not(unix), allow(unused_variables))] pub fn discover_opts( directory: impl AsRef<Path>, Options { required_trust, ceiling_dirs, match_ceiling_dir_or_error, cross_fs, }: Options, ) -> Result<(crate::repository::Path, Trust), Error> { // Absolutize the path so that `Path::parent()` _actually_ gives // us the parent directory. (`Path::parent` just strips off the last // path component, which means it will not do what you expect when // working with paths paths that contain '..'.) let cwd = std::env::current_dir().ok(); let dir = git_path::absolutize(directory.as_ref(), cwd.as_deref()); let dir_metadata = dir.metadata().map_err(|_| Error::InaccessibleDirectory { path: dir.to_path_buf(), })?; if !dir_metadata.is_dir() { return Err(Error::InaccessibleDirectory { path: dir.into_owned() }); } let mut dir_made_absolute = !directory.as_ref().is_absolute() && cwd.as_deref().map_or(false, |cwd| { cwd.strip_prefix(dir.as_ref()) .or_else(|_| dir.as_ref().strip_prefix(cwd)) .is_ok() }); let filter_by_trust = |x: &Path| -> Result<Option<Trust>, Error> { let trust = Trust::from_path_ownership(x).map_err(|err| Error::CheckTrust { path: x.into(), err })?; Ok((trust >= required_trust).then(|| (trust))) }; let max_height = if !ceiling_dirs.is_empty() { let max_height = find_ceiling_height(&dir, &ceiling_dirs, cwd.as_deref()); if max_height.is_none() && match_ceiling_dir_or_error { return Err(Error::NoMatchingCeilingDir); } max_height } else { None }; #[cfg(unix)] let initial_device = device_id(&dir_metadata); let mut cursor = dir.clone().into_owned(); let mut current_height = 0; 'outer: loop { if max_height.map_or(false, |x| current_height > x) { return Err(Error::NoGitRepositoryWithinCeiling { path: dir.into_owned(), ceiling_height: current_height, }); } current_height += 1; #[cfg(unix)] if current_height != 0 && !cross_fs { let metadata = if cursor.as_os_str().is_empty() { Path::new(".") } else { cursor.as_ref() } .metadata() .map_err(|_| Error::InaccessibleDirectory { path: cursor.clone() })?; if device_id(&metadata) != initial_device { return Err(Error::NoGitRepositoryWithinFs { path: dir.into_owned(), limit: cursor.clone(), }); } } for append_dot_git in &[false, true] { if *append_dot_git { cursor.push(DOT_GIT_DIR); } if let Ok(kind) = is_git(&cursor) { match filter_by_trust(&cursor)? { Some(trust) => { // TODO: test this more, it definitely doesn't find the shortest path to a directory let path = if dir_made_absolute { shorten_path_with_cwd(cursor, cwd) } else { cursor }; break 'outer Ok((crate::repository::Path::from_dot_git_dir(path, kind), trust)); } None => { break 'outer Err(Error::NoTrustedGitRepository { path: dir.into_owned(), candidate: cursor, required: required_trust, }) } } } if *append_dot_git { cursor.pop(); } } if !cursor.pop() { if dir_made_absolute || matches!( cursor.components().next(), Some(std::path::Component::RootDir) | Some(std::path::Component::Prefix(_)) ) { break Err(Error::NoGitRepository { path: dir.into_owned() }); } else { dir_made_absolute = true; cursor = if cursor.as_os_str().is_empty() { cwd.clone() } else { // TODO: realpath or absolutize? No test runs into this. Some(git_path::absolutize(&cursor, cwd.as_deref()).into_owned()) } .ok_or(Error::InaccessibleDirectory { path: cursor })?; } } } } fn shorten_path_with_cwd(cursor: PathBuf, cwd: Option<PathBuf>) -> PathBuf { fn comp_len(c: std::path::Component<'_>) -> usize { use std::path::Component::*; match c { Prefix(p) => p.as_os_str().len(), CurDir => 1, ParentDir => 2, Normal(p) => p.len(), RootDir => 1, } } if let Some(cwd) = cwd { debug_assert_eq!(cursor.file_name().and_then(|f| f.to_str()), Some(DOT_GIT_DIR)); let parent = cursor.parent().expect(".git appended"); cwd.strip_prefix(parent) .ok() .and_then(|path_relative_to_cwd| { let relative_path_components = path_relative_to_cwd.components().count(); let current_component_len = cursor.components().map(comp_len).sum::<usize>(); (relative_path_components * "..".len() < current_component_len).then(|| { std::iter::repeat("..") .take(relative_path_components) .chain(Some(DOT_GIT_DIR)) .collect() }) }) .unwrap_or(cursor) } else { cursor } } /// Find the number of components parenting the `search_dir` before the first directory in `ceiling_dirs`. /// `search_dir` needs to be absolutized, and we absolutize every ceiling as well. fn find_ceiling_height(search_dir: &Path, ceiling_dirs: &[PathBuf], cwd: Option<&Path>) -> Option<usize> { ceiling_dirs .iter() .filter_map(|ceiling_dir| { let mut ceiling_dir = git_path::absolutize(ceiling_dir, cwd); match (search_dir.is_absolute(), ceiling_dir.is_absolute()) { (true, false) => ceiling_dir = cwd?.join(ceiling_dir.as_ref()).into(), (false, true) => { let stripped = ceiling_dir.as_ref().strip_prefix(cwd?).ok()?.to_owned(); ceiling_dir = stripped.into(); } (false, false) | (true, true) => {} }; search_dir .strip_prefix(ceiling_dir.as_ref()) .ok() .map(|path_relative_to_ceiling| path_relative_to_ceiling.components().count()) }) .min() } #[cfg(target_os = "linux")] /// Returns the device ID of the directory. fn device_id(m: &fs::Metadata) -> u64 { use std::os::linux::fs::MetadataExt; m.st_dev() } #[cfg(all(unix, not(target_os = "linux")))] /// Returns the device ID of the directory. fn device_id(m: &fs::Metadata) -> u64 { use std::os::unix::fs::MetadataExt; m.dev() } /// Find the location of the git repository directly in `directory` or in any of its parent directories, and provide /// the trust level derived from Path ownership. /// /// Fail if no valid-looking git repository could be found. pub fn discover(directory: impl AsRef<Path>) -> Result<(crate::repository::Path, Trust), Error> { discover_opts(directory, Default::default()) }}
use std::{borrow::Cow, env, path::PathBuf};
use bstr::{ByteSlice, ByteVec};
/// The error returned by [git_discover::discover()][function::discover()].
#[derive(Debug, thiserror::Error)]
#[allow(missing_docs)]
pub enum Error {
#[error("Failed to access a directory, or path is not a directory: '{}'", .path.display())]
InaccessibleDirectory { path: PathBuf },
#[error("Could find a git repository in '{}' or in any of its parents", .path.display())]
NoGitRepository { path: PathBuf },
#[error("Could find a git repository in '{}' or in any of its parents within ceiling height of {}", .path.display(), .ceiling_height)]
NoGitRepositoryWithinCeiling { path: PathBuf, ceiling_height: usize },
#[error("Could find a git repository in '{}' or in any of its parents within device limits below '{}'", .path.display(), .limit.display())]
NoGitRepositoryWithinFs { path: PathBuf, limit: PathBuf },
#[error("None of the passed ceiling directories prefixed the git-dir candidate, making them ineffective.")]
NoMatchingCeilingDir,
#[error("Could find a trusted git repository in '{}' or in any of its parents, candidate at '{}' discarded", .path.display(), .candidate.display())]
NoTrustedGitRepository {
path: PathBuf,
candidate: PathBuf,
required: git_sec::Trust,
},
#[error("Could not determine trust level for path '{}'.", .path.display())]
CheckTrust {
path: PathBuf,
#[source]
err: std::io::Error,
},
}
/// Options to help guide the [discovery][function::discover()] of repositories, along with their options
/// when instantiated.
pub struct Options {
/// When discovering a repository, assure it has at least this trust level or ignore it otherwise.
///
/// This defaults to [`Reduced`][git_sec::Trust::Reduced] as our default settings are geared towards avoiding abuse.
/// Set it to `Full` to only see repositories that [are owned by the current user][git_sec::Trust::from_path_ownership()].
pub required_trust: git_sec::Trust,
/// When discovering a repository, ignore any repositories that are located in these directories or any of their parents.
pub ceiling_dirs: Vec<PathBuf>,
/// If true, and `ceiling_dirs` is not empty, we expect at least one ceiling directory to match or else there will be an error.
pub match_ceiling_dir_or_error: bool,
/// if `true` avoid crossing filesystem boundaries.
/// Only supported on Unix-like systems.
// TODO: test on Linux
// TODO: Handle WASI once https://github.com/rust-lang/rust/issues/71213 is resolved
pub cross_fs: bool,
}
impl Default for Options {
fn default() -> Self {
Options {
required_trust: git_sec::Trust::Reduced,
ceiling_dirs: vec![],
match_ceiling_dir_or_error: true,
cross_fs: false,
}
}
}
impl Options {
/// Loads discovery options overrides from the environment.
///
/// The environment variables are:
/// - `GIT_CEILING_DIRECTORIES` for `ceiling_dirs`
///
/// Note that `GIT_DISCOVERY_ACROSS_FILESYSTEM` for `cross_fs` is **not** read,
/// as it requires parsing of `git-config` style boolean values.
// TODO: test
pub fn apply_environment(mut self) -> Self {
let name = "GIT_CEILING_DIRECTORIES";
if let Some(ceiling_dirs) = env::var_os(name).and_then(|c| Vec::from_os_string(c).ok()) {
self.ceiling_dirs = parse_ceiling_dirs(&ceiling_dirs);
}
self
}
}
/// Parse a byte-string of `:`-separated paths into `Vec<PathBuf>`.
/// Non-absolute paths are discarded.
/// To match git, all paths are normalized, until an empty path is encountered.
fn parse_ceiling_dirs(ceiling_dirs: &[u8]) -> Vec<PathBuf> {
let mut should_normalize = true;
let mut result = Vec::new();
for ceiling_dir in ceiling_dirs.split_str(":") {
if ceiling_dir.is_empty() {
should_normalize = false;
continue;
}
// Paths that are invalid unicode can't be handled
let mut dir = match ceiling_dir.to_path() {
Ok(dir) => Cow::Borrowed(dir),
Err(_) => continue,
};
// Only absolute paths are allowed
if dir.is_relative() {
continue;
}
if should_normalize {
if let Ok(normalized) = git_path::realpath(&dir) {
dir = Cow::Owned(normalized);
}
}
result.push(dir.into_owned());
}
result
}
#[cfg(test)]
mod parse_ceiling_dirs {
use super::*;
#[test]
#[cfg(unix)]
fn from_environment_format() -> std::io::Result<()> {
use std::{fs, os::unix::fs::symlink};
// Setup filesystem
let dir = tempfile::tempdir().expect("success creating temp dir");
let direct_path = dir.path().join("direct");
let symlink_path = dir.path().join("symlink");
fs::create_dir(&direct_path)?;
symlink(&direct_path, &symlink_path)?;
// Parse & build ceiling dirs string
let symlink_str = symlink_path.to_str().expect("symlink path is valid utf8");
let ceiling_dir_string = format!("{}:relative::{}", symlink_str, symlink_str);
let ceiling_dirs = parse_ceiling_dirs(ceiling_dir_string.as_bytes());
assert_eq!(ceiling_dirs.len(), 2, "Relative path is discarded");
assert_eq!(
ceiling_dirs[0],
symlink_path.canonicalize().expect("symlink path exists"),
"Symlinks are resolved"
);
assert_eq!(
ceiling_dirs[1], symlink_path,
"Symlink are not resolved after empty item"
);
dir.close()
}
}
pub(crate) mod function {
#[cfg(unix)]
use std::fs;
use std::path::{Path, PathBuf};
use git_sec::Trust;
use super::{Error, Options};
use crate::{is_git, DOT_GIT_DIR};
/// Find the location of the git repository directly in `directory` or in any of its parent directories and provide
/// an associated Trust level by looking at the git directory's ownership, and control discovery using `options`.
///
/// Fail if no valid-looking git repository could be found.
// TODO: tests for trust-based discovery
#[cfg_attr(not(unix), allow(unused_variables))]
pub fn discover_opts(
directory: impl AsRef<Path>,
Options {
required_trust,
ceiling_dirs,
match_ceiling_dir_or_error,
cross_fs,
}: Options,
) -> Result<(crate::repository::Path, Trust), Error> {
// Absolutize the path so that `Path::parent()` _actually_ gives
// us the parent directory. (`Path::parent` just strips off the last
// path component, which means it will not do what you expect when
// working with paths paths that contain '..'.)
let cwd = std::env::current_dir().ok();
let dir = git_path::absolutize(directory.as_ref(), cwd.as_deref());
let dir_metadata = dir.metadata().map_err(|_| Error::InaccessibleDirectory {
path: dir.to_path_buf(),
})?;
if !dir_metadata.is_dir() {
return Err(Error::InaccessibleDirectory { path: dir.into_owned() });
}
let mut dir_made_absolute = !directory.as_ref().is_absolute()
&& cwd.as_deref().map_or(false, |cwd| {
cwd.strip_prefix(dir.as_ref())
.or_else(|_| dir.as_ref().strip_prefix(cwd))
.is_ok()
});
let filter_by_trust = |x: &Path| -> Result<Option<Trust>, Error> {
let trust = Trust::from_path_ownership(x).map_err(|err| Error::CheckTrust { path: x.into(), err })?;
Ok((trust >= required_trust).then(|| (trust)))
};
let max_height = if !ceiling_dirs.is_empty() {
let max_height = find_ceiling_height(&dir, &ceiling_dirs, cwd.as_deref());
if max_height.is_none() && match_ceiling_dir_or_error {
return Err(Error::NoMatchingCeilingDir);
}
max_height
} else {
None
};
#[cfg(unix)]
let initial_device = device_id(&dir_metadata);
let mut cursor = dir.clone().into_owned();
let mut current_height = 0;
'outer: loop {
if max_height.map_or(false, |x| current_height > x) {
return Err(Error::NoGitRepositoryWithinCeiling {
path: dir.into_owned(),
ceiling_height: current_height,
});
}
current_height += 1;
#[cfg(unix)]
if current_height != 0 && !cross_fs {
let metadata = if cursor.as_os_str().is_empty() {
Path::new(".")
} else {
cursor.as_ref()
}
.metadata()
.map_err(|_| Error::InaccessibleDirectory { path: cursor.clone() })?;
if device_id(&metadata) != initial_device {
return Err(Error::NoGitRepositoryWithinFs {
path: dir.into_owned(),
limit: cursor.clone(),
});
}
}
for append_dot_git in &[false, true] {
if *append_dot_git {
cursor.push(DOT_GIT_DIR);
}
if let Ok(kind) = is_git(&cursor) {
match filter_by_trust(&cursor)? {
Some(trust) => {
// TODO: test this more, it definitely doesn't find the shortest path to a directory
let path = if dir_made_absolute {
shorten_path_with_cwd(cursor, cwd)
} else {
cursor
};
break 'outer Ok((crate::repository::Path::from_dot_git_dir(path, kind), trust));
}
None => {
break 'outer Err(Error::NoTrustedGitRepository {
path: dir.into_owned(),
candidate: cursor,
required: required_trust,
})
}
}
}
if *append_dot_git {
cursor.pop();
}
}
if !cursor.pop() {
if dir_made_absolute
|| matches!(
cursor.components().next(),
Some(std::path::Component::RootDir) | Some(std::path::Component::Prefix(_))
)
{
break Err(Error::NoGitRepository { path: dir.into_owned() });
} else {
dir_made_absolute = true;
cursor = if cursor.as_os_str().is_empty() {
cwd.clone()
} else {
// TODO: realpath or absolutize? No test runs into this.
Some(git_path::absolutize(&cursor, cwd.as_deref()).into_owned())
}
.ok_or(Error::InaccessibleDirectory { path: cursor })?;
}
}
}
}
fn shorten_path_with_cwd(cursor: PathBuf, cwd: Option<PathBuf>) -> PathBuf {
fn comp_len(c: std::path::Component<'_>) -> usize {
use std::path::Component::*;
match c {
Prefix(p) => p.as_os_str().len(),
CurDir => 1,
ParentDir => 2,
Normal(p) => p.len(),
RootDir => 1,
}
}
if let Some(cwd) = cwd {
debug_assert_eq!(cursor.file_name().and_then(|f| f.to_str()), Some(DOT_GIT_DIR));
let parent = cursor.parent().expect(".git appended");
cwd.strip_prefix(parent)
.ok()
.and_then(|path_relative_to_cwd| {
let relative_path_components = path_relative_to_cwd.components().count();
let current_component_len = cursor.components().map(comp_len).sum::<usize>();
(relative_path_components * "..".len() < current_component_len).then(|| {
std::iter::repeat("..")
.take(relative_path_components)
.chain(Some(DOT_GIT_DIR))
.collect()
})
})
.unwrap_or(cursor)
} else {
cursor
}
}
/// Find the number of components parenting the `search_dir` before the first directory in `ceiling_dirs`.
/// `search_dir` needs to be absolutized, and we absolutize every ceiling as well.
fn find_ceiling_height(search_dir: &Path, ceiling_dirs: &[PathBuf], cwd: Option<&Path>) -> Option<usize> {
ceiling_dirs
.iter()
.filter_map(|ceiling_dir| {
let mut ceiling_dir = git_path::absolutize(ceiling_dir, cwd);
match (search_dir.is_absolute(), ceiling_dir.is_absolute()) {
(true, false) => ceiling_dir = cwd?.join(ceiling_dir.as_ref()).into(),
(false, true) => {
let stripped = ceiling_dir.as_ref().strip_prefix(cwd?).ok()?.to_owned();
ceiling_dir = stripped.into();
}
(false, false) | (true, true) => {}
};
search_dir
.strip_prefix(ceiling_dir.as_ref())
.ok()
.map(|path_relative_to_ceiling| path_relative_to_ceiling.components().count())
})
.min()
}
#[cfg(target_os = "linux")]
/// Returns the device ID of the directory.
fn device_id(m: &fs::Metadata) -> u64 {
use std::os::linux::fs::MetadataExt;
m.st_dev()
}
#[cfg(all(unix, not(target_os = "linux")))]
/// Returns the device ID of the directory.
fn device_id(m: &fs::Metadata) -> u64 {
use std::os::unix::fs::MetadataExt;
m.dev()
}
/// Find the location of the git repository directly in `directory` or in any of its parent directories, and provide
/// the trust level derived from Path ownership.
///
/// Fail if no valid-looking git repository could be found.
pub fn discover(directory: impl AsRef<Path>) -> Result<(crate::repository::Path, Trust), Error> {
discover_opts(directory, Default::default())
}
}