use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
pub const ALLOWED_ROOT_PARAM: &str = "allowed_root";
pub const ALLOW_REMOTE_WRITES_PARAM: &str = "allow_remote_writes";
pub fn default_root() -> PathBuf {
if let Some(data) = dirs::data_local_dir() {
return data.join("netget").join("git-workspace");
}
if let Some(home) = dirs::home_dir() {
return home.join(".netget-git-workspace");
}
std::env::temp_dir().join("netget-git-workspace")
}
#[derive(Debug, Clone)]
pub struct GitSandbox {
root: PathBuf,
allow_remote_writes: bool,
}
impl GitSandbox {
pub fn new(configured_root: Option<&str>, allow_remote_writes: bool) -> Result<Self> {
let requested = match configured_root {
Some(raw) if !raw.trim().is_empty() => PathBuf::from(raw.trim()),
_ => default_root(),
};
std::fs::create_dir_all(&requested).with_context(|| {
format!(
"could not create the Git client's {ALLOWED_ROOT_PARAM} directory {}",
requested.display()
)
})?;
let root = requested.canonicalize().with_context(|| {
format!(
"could not canonicalise the Git client's {ALLOWED_ROOT_PARAM} {}",
requested.display()
)
})?;
if !root.is_dir() {
bail!(
"the Git client's {ALLOWED_ROOT_PARAM} ({}) is not a directory",
root.display()
);
}
Ok(Self {
root,
allow_remote_writes,
})
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn remote_writes_allowed(&self) -> bool {
self.allow_remote_writes
}
pub fn require_remote_writes(&self, verb: &str) -> Result<()> {
if self.allow_remote_writes {
return Ok(());
}
bail!(
"{verb} is refused: it writes to a remote repository, which is outside anything \
{ALLOWED_ROOT_PARAM} can confine — the remote URL comes from the cloned \
repository's own config and the push uses this client's credentials. Set the \
`{ALLOW_REMOTE_WRITES_PARAM}` startup parameter to true to permit it. It \
defaults to false because a model that can push can publish or delete work on a \
real forge, and that is not recoverable by deleting the workspace."
);
}
pub fn resolve(&self, raw: &str, context: &str) -> Result<PathBuf> {
let trimmed = raw.trim();
if trimmed.is_empty() {
bail!("{context} is empty");
}
let requested = Path::new(trimmed);
let absolute = if requested.is_absolute() {
requested.to_path_buf()
} else {
self.root.join(requested)
};
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let mut cursor = absolute.clone();
let canonical_prefix = loop {
if let Ok(canonical) = cursor.canonicalize() {
break canonical;
}
let name = cursor.file_name().map(|n| n.to_os_string());
let parent = cursor.parent().map(|p| p.to_path_buf());
match (name, parent) {
(Some(name), Some(parent)) => {
if name == ".." {
bail!(
"{context} ({trimmed}) contains '..' inside a path that does not \
exist, so it cannot be resolved safely"
);
}
tail.push(name);
cursor = parent;
}
_ => bail!(
"{context} ({trimmed}) has no resolvable existing ancestor, so it cannot \
be checked against {ALLOWED_ROOT_PARAM}"
),
}
};
let mut resolved = canonical_prefix;
for name in tail.into_iter().rev() {
resolved.push(name);
}
if !resolved.starts_with(&self.root) {
bail!(
"{context} ({trimmed}) resolves to {} which is outside this Git client's \
{ALLOWED_ROOT_PARAM} ({}). Refused rather than relocated: silently moving it \
inside the root would leave the repository somewhere other than where it was \
asked for. Point it inside the root, or set the `{ALLOWED_ROOT_PARAM}` \
startup parameter to a directory that contains it.",
resolved.display(),
self.root.display()
);
}
Ok(resolved)
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum CloneSource {
Remote,
Local(String),
}
pub fn classify_clone_source(url: &str) -> CloneSource {
let trimmed = url.trim();
if let Some(rest) = trimmed.strip_prefix("file://") {
let path = rest.strip_prefix("localhost/").unwrap_or(rest);
return CloneSource::Local(if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
});
}
if trimmed.contains("://") {
return CloneSource::Remote;
}
if let Some((before_colon, _)) = trimmed.split_once(':') {
if before_colon.contains('@') && !before_colon.contains('/') {
return CloneSource::Remote;
}
}
CloneSource::Local(trimmed.to_string())
}