use std::{
borrow::Cow,
io,
path::{Path, PathBuf},
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GitRepo {
pub remote: GitRemote,
pub revspec: GitRevSpec,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ImportTarget {
LocalPath(PathBuf),
Git(GitImportArg),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GitImportArg {
pub repo: GitRepo,
pub path: PathBuf,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GitRevSpec {
Sha(String),
Symbolic(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GitRemote {
Url(String),
Local(PathBuf),
}
impl GitRemote {
pub fn as_wire(&self) -> Cow<'_, str> {
match self {
GitRemote::Url(s) => Cow::Borrowed(s.as_str()),
GitRemote::Local(p) => Cow::Owned(format!("file://{}", p.display())),
}
}
pub fn cache_key(&self) -> Cow<'_, str> {
self.as_wire()
}
pub fn display_label(&self) -> Cow<'_, str> {
self.as_wire()
}
pub fn parse(s: &str) -> io::Result<Self> {
let s = s.trim();
if let Some(rest) = s.strip_prefix("file://") {
return parse_local_path(rest).map(GitRemote::Local);
}
for scheme in ["https://", "http://", "ssh://"] {
if let Some(rest) = s.strip_prefix(scheme) {
let after_user = match rest.split_once('@') {
Some((user, tail)) if !user.contains('/') => tail,
_ => rest,
};
if !after_user.contains('/') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"git remote `{}` is missing a path component after the host",
s
),
));
}
return Ok(GitRemote::Url(s.to_string()));
}
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"git remote `{}` must be a full URL (`https://`, `http://`, or `ssh://`) \
or `file:///abs/path`",
s
),
))
}
}
pub fn parse_import_target(input: &str) -> io::Result<ImportTarget> {
let trimmed = input.trim();
if let Some(rest) = trimmed.strip_prefix("git+") {
return parse_import_arg_inner(rest).map(ImportTarget::Git);
}
if let Some(rest) = trimmed.strip_prefix("file://") {
let path = parse_local_path(rest)?;
return Ok(ImportTarget::LocalPath(path));
}
if trimmed.contains("://") {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"unsupported `--import` target `{}`: only `file:///<abs>` and \
`git+{{ssh,https,http,file}}://<remote>?rev=<sha>|ref=<name>#<path>` URL shapes are accepted",
input
),
));
}
Ok(ImportTarget::LocalPath(PathBuf::from(trimmed)))
}
pub fn parse_import_arg(input: &str) -> io::Result<GitImportArg> {
let trimmed = input.trim();
let rest = trimmed.strip_prefix("git+").ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"git import argument `{}` must start with the `git+` prefix \
(`git+ssh://`, `git+https://`, `git+http://`, or `git+file://`)",
input
),
)
})?;
parse_import_arg_inner(rest)
}
fn parse_import_arg_inner(rest_after_git_prefix: &str) -> io::Result<GitImportArg> {
let (rest, path) = match rest_after_git_prefix.split_once('#') {
Some((rest, path)) if !path.is_empty() => (rest, path.to_string()),
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"git import is missing the in-repo path; expected `git+<scheme>://<remote>?rev=<sha>|ref=<name>#<path>`",
));
}
};
let (rest, query) = match rest.split_once('?') {
Some((rest, query)) => (rest, Some(query)),
None => (rest, None),
};
let remote = parse_remote_after_scheme(rest)?;
let revspec = parse_query(query)?;
let path_buf = PathBuf::from(&path);
check_in_repo_path(&path_buf)?;
Ok(GitImportArg {
repo: GitRepo { remote, revspec },
path: path_buf,
})
}
fn parse_remote_after_scheme(s: &str) -> io::Result<GitRemote> {
let s = s.trim();
if let Some(rest) = s.strip_prefix("https://") {
let normalized = normalize_hosted_url(strip_user(rest))?;
return Ok(GitRemote::Url(format!("https://{}", normalized)));
}
if let Some(rest) = s.strip_prefix("http://") {
let normalized = normalize_hosted_url(strip_user(rest))?;
return Ok(GitRemote::Url(format!("http://{}", normalized)));
}
if let Some(rest) = s.strip_prefix("ssh://") {
let normalized = normalize_hosted_url(rest)?;
return Ok(GitRemote::Url(format!("ssh://{}", normalized)));
}
if let Some(rest) = s.strip_prefix("file://") {
return parse_local_path(rest).map(GitRemote::Local);
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"unsupported git URL scheme in `git+{}` (expected `ssh`, `https`, `http`, or `file`)",
s
),
))
}
fn strip_user(s: &str) -> &str {
match s.split_once('@') {
Some((user, host_rest)) if !user.contains('/') => host_rest,
_ => s,
}
}
fn normalize_hosted_url(s: &str) -> io::Result<String> {
let s = s.trim_end_matches('/');
let s = s.strip_suffix(".git").unwrap_or(s);
let s = s.trim_end_matches('/');
let slash = s.find('/').ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("git URL `{}` is missing a path component after the host", s),
)
})?;
let host_part = &s[..slash];
let path_part = s[slash + 1..].trim_start_matches('/');
let hostname = host_part.rsplit('@').next().unwrap_or(host_part);
if hostname.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"git URL host must not be empty",
));
}
if path_part.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"git URL for host `{}` is missing a repository path",
hostname
),
));
}
Ok(format!("{}/{}", host_part, path_part))
}
fn parse_local_path(rest_after_file_scheme: &str) -> io::Result<PathBuf> {
let path_str = rest_after_file_scheme;
if path_str.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"`file://` target has no path component",
));
}
if !path_str.starts_with('/') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"`file://` target must be absolute (got `file://{}`); use `file:///<abs>`",
path_str
),
));
}
let path = PathBuf::from(path_str);
for component in path.components() {
if matches!(component, std::path::Component::ParentDir) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"`file://` target `{}` may not contain `..` components",
path.display()
),
));
}
}
std::fs::canonicalize(&path)
.map_err(|err| io::Error::new(err.kind(), format!("`file://{}`: {}", path.display(), err)))
}
fn parse_query(query: Option<&str>) -> io::Result<GitRevSpec> {
let query = query.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"git import requires `?rev=<sha-or-ref>` or `?ref=<name>`",
)
})?;
let mut rev = None;
let mut sym_ref = None;
for pair in query.split('&') {
let (k, v) = pair.split_once('=').ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid git query parameter `{}`", pair),
)
})?;
match k {
"rev" => rev = Some(v.to_string()),
"ref" | "branch" | "tag" => sym_ref = Some(v.to_string()),
"path" => { }
other => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown git query parameter `{}`", other),
));
}
}
}
match (rev, sym_ref) {
(Some(rev), None) => {
if super::is_full_sha1(&rev) {
Ok(GitRevSpec::Sha(rev.to_lowercase()))
} else {
Ok(GitRevSpec::Symbolic(rev))
}
}
(None, Some(name)) => Ok(GitRevSpec::Symbolic(name)),
(Some(_), Some(_)) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"git import accepts either `rev` or `ref/branch/tag`, not both",
)),
(None, None) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"git import is missing `?rev=<sha-or-ref>` or `?ref=<name>`",
)),
}
}
pub(crate) fn check_in_repo_path(path: &Path) -> io::Result<()> {
use std::path::Component;
if path.as_os_str().is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"git import path must not be empty",
));
}
for component in path.components() {
match component {
Component::Normal(_) | Component::CurDir => {}
Component::ParentDir => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"git import path `{}` may not contain `..` traversal",
path.display()
),
));
}
Component::RootDir | Component::Prefix(_) => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("git import path `{}` must be relative", path.display()),
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_cargo_style_https() {
let a = parse_import_arg(
"git+https://gitlab.com/myorg/repo.git?rev=v1.2.3#system/Manifest.toml",
)
.unwrap();
assert_eq!(
a.repo.remote,
GitRemote::Url("https://gitlab.com/myorg/repo".into())
);
assert_eq!(a.repo.revspec, GitRevSpec::Symbolic("v1.2.3".into()));
assert_eq!(a.path, PathBuf::from("system/Manifest.toml"));
}
#[test]
fn parse_http_form() {
let a = parse_import_arg("git+http://example.invalid/o/r?ref=main#m.toml").unwrap();
assert_eq!(
a.repo.remote,
GitRemote::Url("http://example.invalid/o/r".into())
);
}
#[test]
fn parse_ssh_form() {
let a =
parse_import_arg("git+ssh://git@gitlab.com/myorg/repo.git?ref=main#m.toml").unwrap();
assert_eq!(
a.repo.remote,
GitRemote::Url("ssh://git@gitlab.com/myorg/repo".into())
);
assert_eq!(a.repo.revspec, GitRevSpec::Symbolic("main".into()));
assert_eq!(a.path, PathBuf::from("m.toml"));
}
#[test]
fn parse_https_strips_embedded_user() {
let a =
parse_import_arg("git+https://gitlab-ci-token:sometoken@gitlab.com/o/r?rev=abc#m.toml")
.unwrap();
assert_eq!(
a.repo.remote,
GitRemote::Url("https://gitlab.com/o/r".into())
);
}
#[test]
fn parse_full_sha() {
let sha = "5b1f9c2a4d3e0f8b7a6c9d2e1f0a3b4c5d6e7f80";
let a =
parse_import_arg(&format!("git+https://gitlab.com/o/r?rev={}#m.toml", sha)).unwrap();
assert_eq!(a.repo.revspec, GitRevSpec::Sha(sha.to_string()));
}
#[test]
fn missing_path_rejected() {
assert!(parse_import_arg("git+https://gitlab.com/o/r?rev=abc").is_err());
}
#[test]
fn missing_rev_rejected() {
assert!(parse_import_arg("git+https://gitlab.com/o/r#m.toml").is_err());
}
#[test]
fn scp_form_rejected() {
assert!(parse_import_arg("git@gitlab.com:myorg/repo.git?rev=abcdef#m.toml").is_err());
}
#[test]
fn bare_host_path_rejected() {
assert!(parse_import_arg("gitlab.com/myorg/repo?rev=abc#m.toml").is_err());
}
#[test]
fn plain_https_without_git_prefix_rejected() {
assert!(parse_import_arg("https://gitlab.com/o/r?rev=abc#m.toml").is_err());
}
#[test]
fn unknown_scheme_rejected() {
assert!(parse_import_arg("git+ftp://gitlab.com/o/r?rev=abc#m.toml").is_err());
}
#[test]
fn parse_target_local_path_relative() {
let t = parse_import_target("path/to/Manifest.toml").unwrap();
assert_eq!(
t,
ImportTarget::LocalPath(PathBuf::from("path/to/Manifest.toml"))
);
}
#[test]
fn parse_target_file_url_local() {
let dir = tempfile::tempdir().unwrap();
let manifest_path = dir.path().join("Manifest.toml");
std::fs::write(&manifest_path, "").unwrap();
let canonical = std::fs::canonicalize(&manifest_path).unwrap();
let url = format!("file://{}", canonical.display());
let t = parse_import_target(&url).unwrap();
assert_eq!(t, ImportTarget::LocalPath(canonical));
}
#[test]
fn parse_target_file_url_requires_absolute() {
assert!(parse_import_target("file://relative/Manifest.toml").is_err());
}
#[test]
fn parse_target_git_file_url() {
let dir = tempfile::tempdir().unwrap();
let repo_path = dir.path().to_path_buf();
let url = format!(
"git+file://{}?rev=abc#system/Manifest.toml",
repo_path.display()
);
let t = parse_import_target(&url).unwrap();
let canonical = std::fs::canonicalize(&repo_path).unwrap();
let ImportTarget::Git(arg) = t else {
panic!("expected Git target");
};
assert_eq!(arg.repo.remote, GitRemote::Local(canonical));
assert_eq!(arg.path, PathBuf::from("system/Manifest.toml"));
}
#[test]
fn parse_target_unknown_url_rejected() {
assert!(parse_import_target("ftp://example.invalid/m.toml").is_err());
assert!(parse_import_target("https://gitlab.com/o/r?rev=abc#m.toml").is_err());
assert!(parse_import_target("ssh://git@gitlab.com/o/r?rev=abc#m.toml").is_err());
let target = parse_import_target("git@gitlab.com:o/r?rev=abc#m.toml").unwrap();
assert!(matches!(target, ImportTarget::LocalPath(_)));
}
#[test]
fn check_in_repo_path_rejects_traversal() {
assert!(check_in_repo_path(Path::new("../escape")).is_err());
assert!(check_in_repo_path(Path::new("/abs/path")).is_err());
assert!(check_in_repo_path(Path::new("dir/file.toml")).is_ok());
}
#[test]
fn git_remote_parse_url() {
assert_eq!(
GitRemote::parse("https://gitlab.com/o/r").unwrap(),
GitRemote::Url("https://gitlab.com/o/r".into())
);
assert_eq!(
GitRemote::parse("ssh://git@gitlab.com/o/r").unwrap(),
GitRemote::Url("ssh://git@gitlab.com/o/r".into())
);
}
#[test]
fn git_remote_parse_local() {
let dir = tempfile::tempdir().unwrap();
let value = format!("file://{}", dir.path().display());
let remote = GitRemote::parse(&value).unwrap();
let canonical = std::fs::canonicalize(dir.path()).unwrap();
assert_eq!(remote, GitRemote::Local(canonical));
}
#[test]
fn git_remote_parse_rejects_bare_host_path() {
assert!(GitRemote::parse("gitlab.com/o/r").is_err());
}
}