use camino::{Utf8Path, Utf8PathBuf};
use crate::error::{Error, Result};
use crate::preset::TemplateRef;
#[derive(Debug, Clone)]
pub enum TemplateSource {
Local {
root: Utf8PathBuf,
},
Git {
url: String,
rev: Option<String>,
subdir: Option<String>,
},
}
impl TemplateSource {
pub fn from_ref(t: &TemplateRef, base_dir: &Utf8Path) -> Result<Self> {
let s = t.source.trim();
if is_local_path(s) {
let root_only = if Utf8Path::new(s).is_absolute() {
Utf8PathBuf::from(s)
} else {
base_dir.join(s)
};
let normalised_root = normalise(&root_only);
let final_root = if let Some(sub) = &t.subdir {
validate_subdir(s, sub)?;
normalise(&normalised_root.join(sub))
} else {
normalised_root
};
Ok(Self::Local { root: final_root })
} else {
if let Some(sub) = &t.subdir {
validate_subdir(s, sub)?;
}
Ok(Self::Git {
url: normalise_git_url(s),
rev: t.rev.clone(),
subdir: t.subdir.clone(),
})
}
}
pub fn rev_label(&self) -> String {
match self {
Self::Local { .. } => "local".to_string(),
Self::Git { rev, .. } => rev.clone().unwrap_or_else(|| "main".to_string()),
}
}
}
fn is_local_path(s: &str) -> bool {
if s.starts_with("./") || s.starts_with("../") || s.starts_with('/') {
return true;
}
let bytes = s.as_bytes();
if bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'/' || bytes[2] == b'\\')
{
return true;
}
false
}
fn normalise(p: &Utf8Path) -> Utf8PathBuf {
use camino::Utf8Component;
let mut out = Utf8PathBuf::new();
for comp in p.components() {
match comp {
Utf8Component::CurDir => {}
Utf8Component::ParentDir => {
if !out.pop() && !p.is_absolute() {
out.push("..");
}
}
other => out.push(other.as_str()),
}
}
if out.as_str().is_empty() {
out.push(".");
}
out
}
fn validate_subdir(spec: &str, sub: &str) -> Result<()> {
let sub_path = Utf8Path::new(sub);
if sub_path.is_absolute() {
return Err(Error::template(
spec,
format!("subdir `{sub}` must be relative, not absolute"),
));
}
if escapes_via_parent(sub_path) {
return Err(Error::template(
spec,
format!("subdir `{sub}` escapes the source root via `..`"),
));
}
Ok(())
}
pub(crate) fn normalise_git_url(s: &str) -> String {
if s.starts_with("https://")
|| s.starts_with("http://")
|| s.starts_with("ssh://")
|| s.starts_with("git://")
|| s.starts_with("file://")
|| s.starts_with("git+ssh://")
|| s.starts_with("git@")
{
return s.to_string();
}
if s.starts_with("github.com/")
|| s.starts_with("gitlab.com/")
|| s.starts_with("bitbucket.org/")
|| s.starts_with("codeberg.org/")
{
return format!("https://{s}");
}
s.to_string()
}
pub(crate) fn escapes_via_parent(p: &Utf8Path) -> bool {
use camino::Utf8Component;
let mut depth: i32 = 0;
for comp in p.components() {
match comp {
Utf8Component::CurDir => {}
Utf8Component::ParentDir => {
depth -= 1;
if depth < 0 {
return true;
}
}
Utf8Component::Normal(_) => depth += 1,
Utf8Component::RootDir | Utf8Component::Prefix(_) => return true,
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn r(source: &str) -> TemplateRef {
TemplateRef {
source: source.into(),
rev: None,
subdir: None,
}
}
#[test]
fn classifies_relative_local() {
let s = TemplateSource::from_ref(&r("./local/x"), Utf8Path::new("/base")).unwrap();
assert!(matches!(s, TemplateSource::Local { .. }));
assert_eq!(s.rev_label(), "local");
}
#[test]
fn classifies_absolute_local() {
let s = TemplateSource::from_ref(&r("/abs/x"), Utf8Path::new("/base")).unwrap();
assert!(matches!(s, TemplateSource::Local { .. }));
}
#[test]
fn classifies_remote() {
let s = TemplateSource::from_ref(&r("github.com/x/y"), Utf8Path::new("/base")).unwrap();
assert!(matches!(s, TemplateSource::Git { .. }));
}
#[test]
fn git_source_normalises_github_shorthand() {
let s = TemplateSource::from_ref(&r("github.com/x/y"), Utf8Path::new("/base")).unwrap();
match s {
TemplateSource::Git { url, .. } => {
assert_eq!(url, "https://github.com/x/y");
}
_ => panic!("expected Git source"),
}
}
#[test]
fn git_source_passes_through_existing_url() {
let s =
TemplateSource::from_ref(&r("https://example.com/repo.git"), Utf8Path::new("/base"))
.unwrap();
match s {
TemplateSource::Git { url, .. } => {
assert_eq!(url, "https://example.com/repo.git");
}
_ => panic!("expected Git source"),
}
}
#[test]
fn git_source_rejects_absolute_subdir() {
let mut t = r("github.com/x/y");
t.subdir = Some("/etc".into());
let err = TemplateSource::from_ref(&t, Utf8Path::new("/base")).unwrap_err();
assert!(matches!(err, Error::Template { .. }));
}
#[test]
fn rejects_absolute_subdir() {
let mut t = r("./template");
t.subdir = Some("/etc".into());
let err = TemplateSource::from_ref(&t, Utf8Path::new("/base")).unwrap_err();
assert!(matches!(err, Error::Template { .. }));
}
#[test]
fn rejects_subdir_that_escapes_via_parent() {
let mut t = r("./template");
t.subdir = Some("../../../escape".into());
let err = TemplateSource::from_ref(&t, Utf8Path::new("/base")).unwrap_err();
assert!(matches!(err, Error::Template { .. }));
}
#[cfg(unix)]
#[test]
fn normalise_clamps_root_on_absolute_path() {
assert_eq!(normalise(Utf8Path::new("/..")).as_str(), "/");
assert_eq!(normalise(Utf8Path::new("/a/..")).as_str(), "/");
}
#[cfg(unix)]
#[test]
fn normalise_preserves_leading_parent_in_relative() {
assert_eq!(
normalise(Utf8Path::new("../sibling")).as_str(),
"../sibling"
);
}
}