use std::fmt;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
pub const MAX_PATH_BYTES: usize = 255;
pub const MAX_COMPONENT_BYTES: usize = 64;
pub const MAX_COMPONENTS: usize = 4;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[non_exhaustive]
pub enum PathReject {
Empty,
TooLong,
ComponentTooLong,
Nul,
Control,
NonAscii,
ForbiddenCharacter,
Absolute,
HomeReference,
DotDot,
DotComponent,
TrailingDot,
LockSuffix,
LeadingDash,
EmptyComponent,
TooFewComponents,
TooManyComponents,
}
impl fmt::Display for PathReject {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
PathReject::Empty => "empty",
PathReject::TooLong => "longer than 255 bytes",
PathReject::ComponentTooLong => "a component is longer than 64 bytes",
PathReject::Nul => "contains a NUL byte",
PathReject::Control => "contains a control character or a space",
PathReject::NonAscii => "contains a non-ASCII byte",
PathReject::ForbiddenCharacter => "contains a character that is not [A-Za-z0-9._-]",
PathReject::Absolute => "absolute",
PathReject::HomeReference => "a home-directory reference",
PathReject::DotDot => "contains `..`",
PathReject::DotComponent => "a component starts with `.`",
PathReject::TrailingDot => "a component ends with `.`",
PathReject::LockSuffix => "a component ends with `.lock`",
PathReject::LeadingDash => "a component starts with `-`",
PathReject::EmptyComponent => "an empty path component",
PathReject::TooFewComponents => "no namespace: expected `<namespace>/<repo>`",
PathReject::TooManyComponents => "too many path components",
};
f.write_str(s)
}
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
pub struct RepoRef {
namespace: String,
name: String,
}
impl RepoRef {
pub fn new(namespace: &str, name: &str) -> Result<Self> {
let input = format!("{namespace}/{name}");
if namespace.is_empty() {
return Err(reject(&input, PathReject::TooFewComponents));
}
let mut levels = 0usize;
for level in namespace.split('/') {
check_component(&input, level)?;
levels += 1;
}
check_component(&input, name)?;
if levels + 1 > MAX_COMPONENTS {
return Err(reject(&input, PathReject::TooManyComponents));
}
if input.len() > MAX_PATH_BYTES {
return Err(reject(&input, PathReject::TooLong));
}
Ok(RepoRef {
namespace: namespace.to_owned(),
name: name.to_owned(),
})
}
#[cfg(any(test, feature = "conformance"))]
pub(crate) fn unchecked(namespace: &str, name: &str) -> Self {
RepoRef {
namespace: namespace.to_owned(),
name: name.to_owned(),
}
}
pub fn namespace(&self) -> &str {
&self.namespace
}
pub fn name(&self) -> &str {
&self.name
}
pub fn key(&self) -> String {
format!("{}/{}", self.namespace, self.name)
}
pub fn to_ssh_path(&self) -> String {
format!("{}/{}.git", self.namespace, self.name)
}
pub fn path_under(&self, root: &Path) -> Result<PathBuf> {
let root_real = root.canonicalize().map_err(|err| Error::PathEscape {
path: root.to_path_buf(),
reason: format!("the repository root does not resolve: {err}"),
})?;
let target = root_real
.join(&self.namespace)
.join(format!("{}.git", self.name));
let mut probe = target.clone();
loop {
match probe.canonicalize() {
Ok(real) => {
if real != root_real && !real.starts_with(&root_real) {
return Err(Error::PathEscape {
path: probe,
reason: format!(
"resolves to {}, which is outside {}",
real.display(),
root_real.display()
),
});
}
return Ok(target);
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => match probe.parent() {
Some(parent) if parent.starts_with(&root_real) => {
probe = parent.to_path_buf();
}
_ => {
return Err(Error::PathEscape {
path: target,
reason: "no ancestor of the target lies under the repository root"
.to_owned(),
})
}
},
Err(err) => {
return Err(Error::PathEscape {
path: probe,
reason: format!("could not be resolved: {err}"),
})
}
}
}
}
}
impl fmt::Display for RepoRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.namespace, self.name)
}
}
impl fmt::Debug for RepoRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "RepoRef({}/{})", self.namespace, self.name)
}
}
impl std::str::FromStr for RepoRef {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
resolve_repo_path(s)
}
}
fn reject(input: &str, reason: PathReject) -> Error {
Error::InvalidPath {
input: input.escape_debug().to_string(),
reason,
}
}
pub(crate) fn check_component(input: &str, component: &str) -> Result<()> {
if component.is_empty() {
return Err(reject(input, PathReject::EmptyComponent));
}
if component.len() > MAX_COMPONENT_BYTES {
return Err(reject(input, PathReject::ComponentTooLong));
}
if component.contains("..") {
return Err(reject(input, PathReject::DotDot));
}
if component.starts_with('.') {
return Err(reject(input, PathReject::DotComponent));
}
if component.ends_with('.') {
return Err(reject(input, PathReject::TrailingDot));
}
if component.ends_with(".lock") {
return Err(reject(input, PathReject::LockSuffix));
}
if component.starts_with('-') {
return Err(reject(input, PathReject::LeadingDash));
}
for byte in component.bytes() {
match byte {
b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'.' | b'_' | b'-' => {}
0 => return Err(reject(input, PathReject::Nul)),
0x80.. => return Err(reject(input, PathReject::NonAscii)),
0x01..=0x20 | 0x7f => return Err(reject(input, PathReject::Control)),
_ => return Err(reject(input, PathReject::ForbiddenCharacter)),
}
}
Ok(())
}
pub fn resolve_repo_path(input: &str) -> Result<RepoRef> {
if input.contains('\0') {
return Err(reject(input, PathReject::Nul));
}
if input.len() > MAX_PATH_BYTES {
return Err(reject(input, PathReject::TooLong));
}
let mut rest = input;
if rest.is_empty() {
return Err(reject(input, PathReject::Empty));
}
if rest.starts_with('~') {
return Err(reject(input, PathReject::HomeReference));
}
if let Some(stripped) = rest.strip_prefix('/') {
if stripped.starts_with('/') {
return Err(reject(input, PathReject::Absolute));
}
rest = stripped;
}
if rest.is_empty() {
return Err(reject(input, PathReject::Empty));
}
let rest = rest.strip_suffix(".git").unwrap_or(rest);
if rest.is_empty() {
return Err(reject(input, PathReject::Empty));
}
let components: Vec<&str> = rest.split('/').collect();
if components.len() < 2 {
return Err(reject(input, PathReject::TooFewComponents));
}
for component in &components {
check_component(input, component)?;
}
if components.len() > MAX_COMPONENTS {
return Err(reject(input, PathReject::TooManyComponents));
}
let (name, namespace) = components
.split_last()
.expect("at least two components were just checked");
Ok(RepoRef {
namespace: namespace.join("/"),
name: (*name).to_owned(),
})
}
#[cfg(any(test, feature = "conformance"))]
pub mod red {
use super::{RepoRef, Result};
pub const ESCAPES: &[(&str, &str)] = &[
("../etc/passwd", "the plain traversal"),
(
"ns/../../etc/passwd",
"traversal from inside a legal namespace",
),
("ns/repo/../../../root", "traversal past the root"),
("ns/.git/../..", "the `.git/../..` form from the brief"),
("..", "nothing but a traversal"),
("ns/..", "a trailing traversal"),
("a..b/repo", "`..` embedded in a component"),
("//etc/passwd", "double-slash absolute"),
(
"/../etc/passwd",
"traversal behind the tolerated leading slash",
),
("~/repo", "home-directory reference"),
("~root/repo.git", "another user's home directory"),
("ns/re\0po", "NUL byte truncation"),
("\0ns/repo", "leading NUL"),
(
"ns/re\npo",
"newline, a second command to anything line-oriented",
),
("ns/re\tpo", "tab"),
("ns/repo\u{7f}", "DEL"),
("ns/re po", "space"),
("ns/repo\u{1b}[31m", "ANSI escape aimed at the log reader"),
("café/repo", "non-ASCII"),
("ns/repo\u{0301}", "NFD: `o` plus a combining acute"),
("ns/repo", "fullwidth homoglyph of `s`"),
("nѕ/repo", "Cyrillic homoglyph of `s`"),
("ns\u{2044}repo/x", "fraction slash: a `/` that is not `/`"),
("ns/repo\u{200b}x", "zero-width space"),
("ns\\repo", "backslash separator"),
("ns/..\\..\\etc", "Windows-flavoured traversal"),
("", "empty"),
("''", "empty inside quotes"),
("repo", "no namespace at all"),
("repo.git", "no namespace, with the suffix"),
("/repo.git", "no namespace, ssh:// form"),
("ns/", "trailing slash, empty name"),
("/ns/", "trailing slash, ssh:// form"),
("ns//repo", "empty component in the middle"),
("a/b/c/d/e", "deeper than MAX_COMPONENTS"),
("ns/.hidden", "component starting with a dot"),
("ns/.git", "the git directory itself"),
(".git/repo", "the git directory as a namespace"),
("ns/repo.", "component ending in a dot"),
("ns/-rf", "component starting with a dash"),
("-ns/repo", "namespace starting with a dash"),
("ns/repo:extra", "colon, the scp-syntax separator"),
("ns/$(id)", "command substitution"),
("ns/`id`", "backtick substitution"),
("ns/repo;id", "shell separator"),
("ns/re*po", "glob"),
("ns/re'po", "a quote that is not a surrounding quote"),
(
"ns/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"component over 64 bytes",
),
("ns/repo.lock", "[ssh] git's lock-file suffix"),
("ns.lock/repo", "[ssh] the lock suffix on a namespace too"),
("../..", "[ssh] nothing but traversals"),
("ns/repo/../other", "[ssh] traversal back down a sibling"),
("~alice/ns/repo", "[ssh] a named user's home directory"),
("ns/re\\po", "[ssh] a backslash inside a component"),
("ns/repo|nc", "[ssh] a pipe to another process"),
("ns/repo > /etc/cron.d/x", "[ssh] a redirect"),
("ns/repo && id", "[ssh] a command chain"),
(
"ns/\u{0441}ore",
"[ssh] Cyrillic `с`, a homoglyph of ASCII `c`",
),
("ns/repo\u{202e}", "[ssh] a right-to-left override"),
(
"ns/re\u{00ad}po",
"[ssh] a soft hyphen, invisible in a log line",
),
(
"'ns/repo.git'",
"single-quoted; unquoting belongs to the SSH command parser",
),
(
"\"ns/repo.git\"",
"double-quoted, and git never sends double quotes at all",
),
];
pub const LEGITIMATE: &[(&str, &str, &str)] = &[
("ns/repo", "ns", "repo"),
("ns/repo.git", "ns", "repo"),
("/ns/repo.git", "ns", "repo"),
("team/sub/repo.git", "team/sub", "repo"),
("team/sub/deeper/repo.git", "team/sub/deeper", "repo"),
("/team/sub/repo.git", "team/sub", "repo"),
(
"My-Team_1/some.repo-name_2.git",
"My-Team_1",
"some.repo-name_2",
),
("ns/repo.git.git", "ns", "repo.git"),
("ns/a", "ns", "a"),
("0/9", "0", "9"),
("nordisk/osm-katana.git", "nordisk", "osm-katana"),
("/nordisk/osm-katana.git", "nordisk", "osm-katana"),
("a/b", "a", "b"),
("team-1/repo_2.name", "team-1", "repo_2.name"),
("team-1/my_repo.v2.git", "team-1", "my_repo.v2"),
("x.git/repo", "x.git", "repo"),
("/etc/passwd", "etc", "passwd"),
(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/b",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"b",
),
];
pub fn broken_resolve_repo_path(input: &str) -> Result<RepoRef> {
let rest = input.trim_matches(['\'', '"']);
let rest = rest.strip_prefix('/').unwrap_or(rest);
let rest = rest.strip_suffix(".git").unwrap_or(rest);
match rest.split_once('/') {
Some((namespace, name)) => Ok(RepoRef::unchecked(namespace, name)),
None => Ok(RepoRef::unchecked("", rest)),
}
}
}