use std::{
ffi::OsStr,
path::{Component, Path, PathBuf},
process::Command,
};
use super::{contain, state::GitRecord};
const REMOTE_PREFIXES: &[&str] = &["https://", "http://", "ssh://", "git://", "file://", "git@"];
const GIT_LOCATION_ENV: &[&str] = &[
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_COMMON_DIR",
"GIT_NAMESPACE",
];
const GITHUB_HOSTS: &[&str] = &["github.com", "www.github.com"];
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum InstallSource {
Local(PathBuf),
Git(GitSource),
}
impl InstallSource {
pub(crate) fn parse(arg: &OsStr, cwd: &Path) -> anyhow::Result<Self> {
let path = Path::new(arg);
let local = || Ok(Self::Local(path.to_path_buf()));
let Some(arg) = arg.to_str() else {
return local();
};
if let Some(remote) = remote_spelling(arg) {
return github_source(&remote)
.unwrap_or_else(|| GitSource::new(remote, None, None))
.map(Self::Git);
}
if cwd.join(path).exists() {
return local();
}
if let Some(source) = github_source(arg) {
return source.map(Self::Git);
}
local()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GitSource {
url: String,
git_ref: Option<String>,
subdir: Option<String>,
}
impl GitSource {
pub(crate) fn new(
url: String,
git_ref: Option<String>,
subdir: Option<String>,
) -> anyhow::Result<Self> {
if !REMOTE_PREFIXES.iter().any(|prefix| url.starts_with(prefix)) {
anyhow::bail!(
"unsupported git URL `{url}`: expected one of {}",
REMOTE_PREFIXES.join(", ")
);
}
if embeds_credentials(&url) {
anyhow::bail!("{CREDENTIALS_REFUSED}");
}
if let Some(git_ref) = &git_ref {
if git_ref.is_empty() || git_ref.starts_with('-') {
anyhow::bail!("invalid git ref `{git_ref}`");
}
}
if let Some(subdir) = &subdir {
let path = Path::new(subdir);
if subdir.is_empty()
|| !path
.components()
.all(|component| matches!(component, Component::Normal(_)))
{
anyhow::bail!(
"invalid plugin path `{subdir}`: must be relative to the repository root without `.` or `..`"
);
}
}
Ok(Self {
url,
git_ref,
subdir,
})
}
pub(crate) fn url(&self) -> &str {
&self.url
}
pub(crate) fn subdir(&self) -> Option<&str> {
self.subdir.as_deref()
}
pub(crate) fn is_github(&self) -> bool {
url::Url::parse(&self.url).is_ok_and(|url| is_github_web(&url))
}
pub(crate) fn record(&self, commit: String) -> GitRecord {
GitRecord {
url: self.url.clone(),
git_ref: self.git_ref.clone(),
subdir: self.subdir.clone(),
commit,
}
}
pub(crate) fn describe(&self) -> String {
let mut details = Vec::new();
if let Some(git_ref) = &self.git_ref {
details.push(format!("ref {git_ref}"));
}
if let Some(subdir) = &self.subdir {
details.push(format!("path {subdir}"));
}
if details.is_empty() {
self.url.clone()
} else {
format!("{} ({})", self.url, details.join(", "))
}
}
}
impl TryFrom<&GitRecord> for GitSource {
type Error = anyhow::Error;
fn try_from(record: &GitRecord) -> anyhow::Result<Self> {
Self::new(
record.url.clone(),
record.git_ref.clone(),
record.subdir.clone(),
)
}
}
fn remote_spelling(arg: &str) -> Option<String> {
if let Some((scheme, rest)) = arg.split_once("://") {
let is_scheme = scheme
.chars()
.next()
.is_some_and(|first| first.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
if is_scheme {
return Some(format!("{}://{rest}", scheme.to_ascii_lowercase()));
}
}
arg.starts_with("git@").then(|| arg.to_string())
}
const CREDENTIALS_REFUSED: &str =
"refusing a git URL with embedded credentials; use a git credential helper or SSH key instead";
fn embeds_credentials(url: &str) -> bool {
let Ok(parsed) = url::Url::parse(url) else {
return false;
};
parsed.password().is_some()
|| matches!(parsed.scheme(), "http" | "https") && !parsed.username().is_empty()
}
fn is_github_web(url: &url::Url) -> bool {
matches!(url.scheme(), "http" | "https")
&& url
.host_str()
.is_some_and(|host| GITHUB_HOSTS.contains(&host))
}
fn github_source(arg: &str) -> Option<anyhow::Result<GitSource>> {
if arg.starts_with("git@") {
return None;
}
let with_scheme = if arg.contains("://") {
arg.to_string()
} else {
format!("https://{arg}")
};
if !is_github_web(&url::Url::parse(&with_scheme).ok()?) {
return None;
}
Some(github_source_from(&with_scheme))
}
fn github_source_from(url: &str) -> anyhow::Result<GitSource> {
if embeds_credentials(url) {
anyhow::bail!("{CREDENTIALS_REFUSED}");
}
let after_scheme = url.split_once("://").map_or("", |(_, rest)| rest);
let path = after_scheme
.find('/')
.map_or("", |start| &after_scheme[start..]);
let path = path.split(['?', '#']).next().unwrap_or_default();
let mut segments = path.split('/').filter(|segment| !segment.is_empty());
let (Some(owner), Some(repo)) = (segments.next(), segments.next()) else {
anyhow::bail!("unsupported GitHub URL: expected github.com/<owner>/<repo>");
};
let repo = repo.strip_suffix(".git").unwrap_or(repo);
let (git_ref, subdir) = match segments.next() {
None => (None, None),
Some("tree") => {
let Some(git_ref) = segments.next() else {
anyhow::bail!("unsupported GitHub URL: `tree/` needs a ref");
};
let subdir = segments.map(decode_segment).collect::<anyhow::Result<Vec<_>>>()?;
let subdir = subdir.join("/");
(
Some(decode_segment(git_ref)?),
(!subdir.is_empty()).then_some(subdir),
)
}
Some(other) => anyhow::bail!(
"unsupported GitHub URL segment `{other}`: use github.com/<owner>/<repo>/tree/<ref>/<path>"
),
};
GitSource::new(
format!("https://github.com/{owner}/{repo}"),
git_ref,
subdir,
)
}
fn decode_segment(segment: &str) -> anyhow::Result<String> {
percent_encoding::percent_decode_str(segment)
.decode_utf8()
.map(|decoded| decoded.into_owned())
.map_err(|_| anyhow::anyhow!("GitHub URL segment `{segment}` is not valid UTF-8"))
}
pub(crate) struct Checkout {
_dir: tempfile::TempDir,
repo: PathBuf,
pub(crate) commit: String,
}
impl Checkout {
pub(crate) fn package_dir(&self, source: &GitSource) -> anyhow::Result<PathBuf> {
let Some(subdir) = source.subdir() else {
return Ok(self.repo.clone());
};
let canonical =
contain::canonical_root(&self.repo).map_err(|error| anyhow::anyhow!(error))?;
contain::contained_path(&canonical, &canonical.join(subdir)).map_err(|_| {
anyhow::anyhow!(
"path `{subdir}` does not exist in {} at {}",
source.url(),
short_commit(&self.commit)
)
})
}
}
pub(crate) fn fetch(source: &GitSource) -> anyhow::Result<Checkout> {
let dir = tempfile::Builder::new().prefix("rho-plugin-").tempdir()?;
let no_hooks = dir.path().join("no-hooks");
let repo = dir.path().join("repo");
std::fs::create_dir(&no_hooks)?;
std::fs::create_dir(&repo)?;
let git = Git {
repo: &repo,
no_hooks: &no_hooks,
};
let template = format!("--template={}", no_hooks.display());
git.run(&["init", "--quiet", &template])?;
let target = source.git_ref.as_deref().unwrap_or("HEAD");
git.run(&[
"fetch",
"--quiet",
"--depth",
"1",
"--",
source.url(),
target,
])
.map_err(|error| anyhow::anyhow!("could not fetch {}: {error}", source.describe()))?;
git.run(&[
"-c",
"advice.detachedHead=false",
"checkout",
"--quiet",
"FETCH_HEAD",
])?;
let commit = git.run(&["rev-parse", "HEAD"])?;
std::fs::remove_dir_all(repo.join(".git"))?;
Ok(Checkout {
_dir: dir,
repo,
commit,
})
}
pub(crate) fn short_commit(commit: &str) -> &str {
commit.get(..12).unwrap_or(commit)
}
struct Git<'a> {
repo: &'a Path,
no_hooks: &'a Path,
}
impl Git<'_> {
fn run(&self, args: &[&str]) -> anyhow::Result<String> {
let mut command = Command::new("git");
for name in GIT_LOCATION_ENV {
command.env_remove(name);
}
let output = command
.arg("-C")
.arg(self.repo)
.arg("-c")
.arg(format!("core.hooksPath={}", self.no_hooks.display()))
.args(args)
.output()
.map_err(|error| match error.kind() {
std::io::ErrorKind::NotFound => anyhow::anyhow!(
"git is not installed; install git to use repository plugin sources"
),
_ => anyhow::anyhow!("could not run git: {error}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"git {} failed: {}",
args.first().copied().unwrap_or_default(),
stderr.trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
}
#[cfg(test)]
#[path = "git_source_tests.rs"]
mod tests;