use std::fmt;
use std::io;
use std::os::unix::fs::MetadataExt;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use trusty_common::crate_config::{crate_config_path_at, load_at};
use trusty_common::workspace_layout::expand_tilde;
use trusty_mpm::core::child_repo_scan::{
ChildRepoScan, scan_for_child_repo, scan_for_child_repo_except,
};
use trusty_mpm::core::trusty_tools_config::{CRATE_NAME, TrustyToolsConfig, WORKSPACE_ROOT_ENV};
use super::user_config_path;
const PROJECTS_ROOT_DIR: &str = "trusty-mpm-projects";
pub(crate) const PRIVATE_REPO_DIR: &str = "local";
const GIT_REDIRECT_VARS: &[&str] = &["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DirRefusal {
Unresolvable { path: PathBuf, reason: String },
FilesystemRoot(PathBuf),
Home(PathBuf),
WorkspaceParent { path: PathBuf, reason: String },
ConfigDir { path: PathBuf, config_dir: PathBuf },
InsideWorkTree { path: PathBuf, work_tree: PathBuf },
HasRemote { path: PathBuf, remotes: String },
GitProbeFailed { path: PathBuf, detail: String },
}
impl fmt::Display for DirRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
const FIX: &str = "omit --dir to use ~/trusty-mpm-projects/architect, or pass a new, \
empty directory";
match self {
Self::Unresolvable { path, reason } => write!(
f,
"refusing {}: cannot check it ({reason}); fix the path or its permissions, or {FIX}",
path.display()
),
Self::FilesystemRoot(p) => write!(
f,
"refusing {}: it is a filesystem root; {FIX}",
p.display()
),
Self::Home(p) => write!(
f,
"refusing {}: it is your home directory; {FIX}",
p.display()
),
Self::WorkspaceParent { path, reason } => write!(
f,
"refusing {}: it is a workspace parent ({reason}); {FIX}",
path.display()
),
Self::ConfigDir { path, config_dir } => write!(
f,
"refusing {}: it overlaps the tm config directory {}, which holds the \
`[supervisor] projects` allowlist; {FIX}",
path.display(),
config_dir.display()
),
Self::InsideWorkTree { path, work_tree } => write!(
f,
"refusing {}: it is inside the git work tree {}; {FIX} outside any repository",
path.display(),
work_tree.display()
),
Self::HasRemote { path, remotes } => write!(
f,
"refusing {}: it is a git repository with remote(s) {remotes}; the Architect's \
repository must start with no remote; {FIX}",
path.display()
),
Self::GitProbeFailed { path, detail } => write!(
f,
"refusing {}: `git remote` failed ({detail}); repair the repository, or {FIX}",
path.display()
),
}
}
}
impl std::error::Error for DirRefusal {}
pub(crate) fn check(dir: &Path, home: &Path) -> Result<PathBuf, DirRefusal> {
let target = resolve(dir)?;
if is_filesystem_root(&target)? {
return Err(DirRefusal::FilesystemRoot(target));
}
let home = std::fs::canonicalize(home).map_err(|e| unresolvable(home, &e))?;
if target == home {
return Err(DirRefusal::Home(target));
}
if home.starts_with(&target) {
return Err(DirRefusal::WorkspaceParent {
reason: format!("it contains your home directory {}", home.display()),
path: target,
});
}
refuse_config_dir(&target, &home)?;
refuse_workspace_parent(&target, &home)?;
refuse_enclosing_work_tree(&target)?;
refuse_remote(&target)?;
Ok(target)
}
fn resolve(path: &Path) -> Result<PathBuf, DirRefusal> {
let abs = std::path::absolute(path).map_err(|e| unresolvable(path, &e))?;
let mut tail = Vec::new();
let mut cur = abs.as_path();
loop {
match std::fs::canonicalize(cur) {
Ok(mut real) => {
real.extend(tail.iter().rev());
return Ok(real);
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
if std::fs::symlink_metadata(cur).is_ok() {
return Err(DirRefusal::Unresolvable {
path: abs.clone(),
reason: format!("{} is a dangling symlink", cur.display()),
});
}
let (Some(parent), Some(Component::Normal(name))) =
(cur.parent(), cur.components().next_back())
else {
return Err(DirRefusal::Unresolvable {
path: abs.clone(),
reason: "`..` under a directory that does not exist".to_owned(),
});
};
tail.push(name.to_owned());
cur = parent;
}
Err(e) => return Err(unresolvable(&abs, &e)),
}
}
}
fn is_filesystem_root(target: &Path) -> Result<bool, DirRefusal> {
let Some(parent) = target.parent() else {
return Ok(true);
};
let own = match std::fs::metadata(target) {
Ok(meta) => meta.dev(),
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(unresolvable(target, &e)),
};
let above = std::fs::metadata(parent).map_err(|e| unresolvable(parent, &e))?;
Ok(own != above.dev())
}
fn refuse_config_dir(target: &Path, home: &Path) -> Result<(), DirRefusal> {
let config_path = user_config_path(home);
let Some(config_dir) = config_path.parent() else {
return Ok(());
};
let config_dir = resolve(config_dir)?;
if target.starts_with(&config_dir) || config_dir.starts_with(target) {
return Err(DirRefusal::ConfigDir {
path: target.to_path_buf(),
config_dir,
});
}
Ok(())
}
fn projects_roots(target: &Path, home: &Path) -> Result<Vec<PathBuf>, DirRefusal> {
let expand = |raw: Option<&str>| {
raw.map(str::trim)
.filter(|raw| !raw.is_empty())
.map(|raw| expand_tilde(raw, home))
};
let mut roots = vec![home.join(PROJECTS_ROOT_DIR)];
let config = crate_config_path_at(home, CRATE_NAME);
match load_at::<TrustyToolsConfig>(&config) {
Ok(None) => {}
Ok(Some(cfg)) => roots.extend(expand(cfg.workspace_root_template.as_deref())),
Err(e) => {
return Err(DirRefusal::Unresolvable {
path: target.to_path_buf(),
reason: format!("the trusty-mpm config did not load: {e}"),
});
}
}
roots.extend(expand(std::env::var(WORKSPACE_ROOT_ENV).ok().as_deref()));
Ok(roots)
}
fn refuse_workspace_parent(target: &Path, home: &Path) -> Result<(), DirRefusal> {
let parent = |reason: String| DirRefusal::WorkspaceParent {
path: target.to_path_buf(),
reason,
};
for root in projects_roots(target, home)? {
let root = resolve(&root)?;
if root.starts_with(target) {
return Err(parent(format!(
"it holds the projects root {}",
root.display()
)));
}
}
let scan = match private_repo(target)? {
None => scan_for_child_repo(target),
Some(local) => {
check_private_repo(target, &local)?;
scan_for_child_repo_except(target, &local)
}
};
match scan {
ChildRepoScan::Clear => Ok(()),
ChildRepoScan::Found(repo) => Err(parent(format!(
"it contains the repository {}",
repo.display()
))),
ChildRepoScan::Incomplete(why) => Err(DirRefusal::Unresolvable {
path: target.to_path_buf(),
reason: format!("the scan for repositories inside it did not finish: {why}"),
}),
}
}
fn private_repo(target: &Path) -> Result<Option<PathBuf>, DirRefusal> {
let local = target.join(PRIVATE_REPO_DIR);
for path in [local.clone(), local.join(".git")] {
match std::fs::symlink_metadata(&path) {
Ok(meta) if meta.is_dir() => {}
Ok(_) => return Ok(None),
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(unresolvable(&path, &e)),
}
}
Ok(Some(local))
}
fn check_private_repo(target: &Path, local: &Path) -> Result<(), DirRefusal> {
let parent = |reason: String| DirRefusal::WorkspaceParent {
path: target.to_path_buf(),
reason,
};
match scan_for_child_repo(local) {
ChildRepoScan::Clear => {}
ChildRepoScan::Found(repo) => {
return Err(parent(format!(
"it contains the repository {}, nested in its private repository {}",
repo.display(),
local.display()
)));
}
ChildRepoScan::Incomplete(why) => {
return Err(DirRefusal::Unresolvable {
path: local.to_path_buf(),
reason: format!("the scan for repositories inside it did not finish: {why}"),
});
}
}
let remotes = list_remotes(local)?;
if remotes.is_empty() {
return Ok(());
}
Err(parent(format!(
"its private repository {} has remote(s) {}; only a `{PRIVATE_REPO_DIR}/` repository \
with no remote is accepted",
local.display(),
remotes.join(", ")
)))
}
fn refuse_enclosing_work_tree(target: &Path) -> Result<(), DirRefusal> {
for ancestor in target.ancestors().skip(1) {
let git = ancestor.join(".git");
match git.try_exists() {
Ok(false) => {}
Ok(true) => {
return Err(DirRefusal::InsideWorkTree {
path: target.to_path_buf(),
work_tree: ancestor.to_path_buf(),
});
}
Err(e) => return Err(unresolvable(&git, &e)),
}
}
Ok(())
}
fn refuse_remote(target: &Path) -> Result<(), DirRefusal> {
match target.join(".git").try_exists() {
Ok(false) => return Ok(()),
Ok(true) => {}
Err(e) => return Err(unresolvable(&target.join(".git"), &e)),
}
let remotes = list_remotes(target)?;
if remotes.is_empty() {
return Ok(());
}
Err(DirRefusal::HasRemote {
path: target.to_path_buf(),
remotes: remotes.join(", "),
})
}
fn list_remotes(repo: &Path) -> Result<Vec<String>, DirRefusal> {
let probe_failed = |detail: String| DirRefusal::GitProbeFailed {
path: repo.to_path_buf(),
detail,
};
let out = git()
.arg("-C")
.arg(repo)
.arg("remote")
.output()
.map_err(|e| probe_failed(format!("cannot run git: {e}")))?;
if !out.status.success() {
return Err(probe_failed(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
));
}
Ok(String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.map(str::to_owned)
.collect())
}
pub(super) fn git() -> Command {
let mut cmd = Command::new("git");
for var in GIT_REDIRECT_VARS {
cmd.env_remove(var);
}
cmd
}
fn unresolvable(path: &Path, err: &io::Error) -> DirRefusal {
DirRefusal::Unresolvable {
path: path.to_path_buf(),
reason: err.to_string(),
}
}
#[cfg(test)]
#[path = "preflight_tests.rs"]
mod tests;