Add typed SSH remote endpoints
This commit is contained in:
@@ -420,26 +420,9 @@ impl Config {
|
||||
.ok_or(ConfigError::InvalidField { field: "git" })?;
|
||||
match remote {
|
||||
Some(remote) => {
|
||||
let mut configured = toml::Table::new();
|
||||
configured.insert(
|
||||
"name".to_owned(),
|
||||
toml::Value::String(remote.name().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"url".to_owned(),
|
||||
toml::Value::String(remote.url().to_string()),
|
||||
);
|
||||
configured.insert(
|
||||
"server_id".to_owned(),
|
||||
toml::Value::String(remote.server_id().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"application_id".to_owned(),
|
||||
toml::Value::String(remote.application_id().as_str().to_owned()),
|
||||
);
|
||||
git.insert(
|
||||
"remotes".to_owned(),
|
||||
toml::Value::Array(vec![toml::Value::Table(configured)]),
|
||||
toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote))]),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
@@ -511,27 +494,10 @@ impl Config {
|
||||
toml::Value::String(path_text(key_material, "key_material")?),
|
||||
);
|
||||
if let Some(remote) = remote {
|
||||
let mut configured = toml::Table::new();
|
||||
configured.insert(
|
||||
"name".to_owned(),
|
||||
toml::Value::String(remote.name().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"url".to_owned(),
|
||||
toml::Value::String(remote.url().to_string()),
|
||||
);
|
||||
configured.insert(
|
||||
"server_id".to_owned(),
|
||||
toml::Value::String(remote.server_id().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"application_id".to_owned(),
|
||||
toml::Value::String(remote.application_id().as_str().to_owned()),
|
||||
);
|
||||
let mut git = toml::Table::new();
|
||||
git.insert(
|
||||
"remotes".to_owned(),
|
||||
toml::Value::Array(vec![toml::Value::Table(configured)]),
|
||||
toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote))]),
|
||||
);
|
||||
root.insert("git".to_owned(), toml::Value::Table(git));
|
||||
}
|
||||
@@ -768,6 +734,29 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
fn git_remote_document(remote: &GitRemote) -> toml::Table {
|
||||
let mut configured = toml::Table::new();
|
||||
configured.insert(
|
||||
"name".to_owned(),
|
||||
toml::Value::String(remote.name().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"url".to_owned(),
|
||||
toml::Value::String(remote.url().to_owned()),
|
||||
);
|
||||
if let Some((server_id, application_id)) = remote.https_credentials() {
|
||||
configured.insert(
|
||||
"server_id".to_owned(),
|
||||
toml::Value::String(server_id.as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"application_id".to_owned(),
|
||||
toml::Value::String(application_id.as_str().to_owned()),
|
||||
);
|
||||
}
|
||||
configured
|
||||
}
|
||||
|
||||
/// Deterministic path context for configuration loading.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ConfigLoader {
|
||||
@@ -876,12 +865,264 @@ identifier_type!(RemoteName);
|
||||
identifier_type!(ServerId);
|
||||
identifier_type!(ApplicationId);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RemoteTransport {
|
||||
Https,
|
||||
Ssh,
|
||||
}
|
||||
|
||||
impl fmt::Display for RemoteTransport {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::Https => "HTTPS",
|
||||
Self::Ssh => "SSH",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum RemoteEndpoint {
|
||||
Https(Url),
|
||||
Ssh(SshEndpoint),
|
||||
}
|
||||
|
||||
impl RemoteEndpoint {
|
||||
pub fn parse(value: &str) -> Result<Self, InvalidRemoteEndpoint> {
|
||||
if value.is_empty() || value.chars().any(char::is_control) {
|
||||
return Err(InvalidRemoteEndpoint);
|
||||
}
|
||||
if value.contains("://") {
|
||||
let parsed = Url::parse(value).map_err(|_| InvalidRemoteEndpoint)?;
|
||||
return match parsed.scheme() {
|
||||
"https"
|
||||
if parsed.host_str().is_some()
|
||||
&& parsed.username().is_empty()
|
||||
&& parsed.password().is_none()
|
||||
&& parsed.query().is_none()
|
||||
&& parsed.fragment().is_none() =>
|
||||
{
|
||||
Ok(Self::Https(parsed))
|
||||
}
|
||||
"ssh" => parse_ssh_url(parsed).map(Self::Ssh),
|
||||
_ => Err(InvalidRemoteEndpoint),
|
||||
};
|
||||
}
|
||||
parse_scp_remote(value).map(Self::Ssh)
|
||||
}
|
||||
|
||||
pub const fn transport(&self) -> RemoteTransport {
|
||||
match self {
|
||||
Self::Https(_) => RemoteTransport::Https,
|
||||
Self::Ssh(_) => RemoteTransport::Ssh,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn as_https(&self) -> Option<&Url> {
|
||||
match self {
|
||||
Self::Https(url) => Some(url),
|
||||
Self::Ssh(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn as_ssh(&self) -> Option<&SshEndpoint> {
|
||||
match self {
|
||||
Self::Https(_) => None,
|
||||
Self::Ssh(endpoint) => Some(endpoint),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SshEndpoint {
|
||||
user: Option<String>,
|
||||
host: String,
|
||||
port: u16,
|
||||
path: SshRepositoryPath,
|
||||
}
|
||||
|
||||
impl SshEndpoint {
|
||||
pub fn user(&self) -> Option<&str> {
|
||||
self.user.as_deref()
|
||||
}
|
||||
|
||||
pub fn host(&self) -> &str {
|
||||
&self.host
|
||||
}
|
||||
|
||||
pub const fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
pub const fn path(&self) -> &SshRepositoryPath {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SshRepositoryPath {
|
||||
Absolute(String),
|
||||
Relative(String),
|
||||
Tilde(String),
|
||||
}
|
||||
|
||||
impl SshRepositoryPath {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Absolute(path) | Self::Relative(path) | Self::Tilde(path) => path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct InvalidRemoteEndpoint;
|
||||
|
||||
impl fmt::Display for InvalidRemoteEndpoint {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("invalid or forbidden Git remote endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for InvalidRemoteEndpoint {}
|
||||
|
||||
fn parse_ssh_url(url: Url) -> Result<SshEndpoint, InvalidRemoteEndpoint> {
|
||||
if url.password().is_some() || url.query().is_some() || url.fragment().is_some() {
|
||||
return Err(InvalidRemoteEndpoint);
|
||||
}
|
||||
let user = parse_ssh_user(url.username())?;
|
||||
let host = url.host_str().ok_or(InvalidRemoteEndpoint)?.to_owned();
|
||||
let port = url.port().unwrap_or(22);
|
||||
if port == 0 {
|
||||
return Err(InvalidRemoteEndpoint);
|
||||
}
|
||||
let path = percent_encoding::percent_decode_str(url.path())
|
||||
.decode_utf8()
|
||||
.map_err(|_| InvalidRemoteEndpoint)?;
|
||||
let path = if let Some(tilde) = path.strip_prefix("/~") {
|
||||
parse_ssh_path(format!("~{tilde}"), true)?
|
||||
} else {
|
||||
parse_ssh_path(path.into_owned(), false)?
|
||||
};
|
||||
Ok(SshEndpoint {
|
||||
user,
|
||||
host,
|
||||
port,
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_scp_remote(value: &str) -> Result<SshEndpoint, InvalidRemoteEndpoint> {
|
||||
if value.contains(['?', '#']) || value.to_ascii_lowercase().starts_with("ext::") {
|
||||
return Err(InvalidRemoteEndpoint);
|
||||
}
|
||||
let separator = if let Some(bracket) = value.find('[') {
|
||||
let close = value[bracket + 1..]
|
||||
.find(']')
|
||||
.map(|index| bracket + index + 1)
|
||||
.ok_or(InvalidRemoteEndpoint)?;
|
||||
if value.as_bytes().get(close + 1) != Some(&b':') {
|
||||
return Err(InvalidRemoteEndpoint);
|
||||
}
|
||||
close + 1
|
||||
} else {
|
||||
value.find(':').ok_or(InvalidRemoteEndpoint)?
|
||||
};
|
||||
let (authority, path) = value.split_at(separator);
|
||||
let path = path.strip_prefix(':').ok_or(InvalidRemoteEndpoint)?;
|
||||
if path.contains(':') {
|
||||
return Err(InvalidRemoteEndpoint);
|
||||
}
|
||||
let (user, host) = match authority.split_once('@') {
|
||||
Some((user, host)) if !host.contains('@') => (parse_ssh_user(user)?, host),
|
||||
Some(_) => return Err(InvalidRemoteEndpoint),
|
||||
None => (None, authority),
|
||||
};
|
||||
if host.is_empty()
|
||||
|| host.starts_with('-')
|
||||
|| (host.len() == 1
|
||||
&& host.as_bytes()[0].is_ascii_alphabetic()
|
||||
&& path.starts_with(['/', '\\']))
|
||||
{
|
||||
return Err(InvalidRemoteEndpoint);
|
||||
}
|
||||
let bracketed = host.starts_with('[') && host.ends_with(']');
|
||||
if host.contains(['[', ']']) && !bracketed || host.contains(':') && !bracketed {
|
||||
return Err(InvalidRemoteEndpoint);
|
||||
}
|
||||
let host = host
|
||||
.strip_prefix('[')
|
||||
.and_then(|host| host.strip_suffix(']'))
|
||||
.unwrap_or(host);
|
||||
let host = url::Host::parse(host)
|
||||
.map_err(|_| InvalidRemoteEndpoint)?
|
||||
.to_string();
|
||||
Ok(SshEndpoint {
|
||||
user,
|
||||
host,
|
||||
port: 22,
|
||||
path: parse_ssh_path(path.to_owned(), true)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_ssh_user(value: &str) -> Result<Option<String>, InvalidRemoteEndpoint> {
|
||||
if value.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if value.starts_with('-')
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return Err(InvalidRemoteEndpoint);
|
||||
}
|
||||
Ok(Some(value.to_owned()))
|
||||
}
|
||||
|
||||
fn parse_ssh_path(
|
||||
path: String,
|
||||
allow_relative: bool,
|
||||
) -> Result<SshRepositoryPath, InvalidRemoteEndpoint> {
|
||||
if path.is_empty()
|
||||
|| path == "/"
|
||||
|| path.contains(['?', '#'])
|
||||
|| path.chars().any(char::is_control)
|
||||
{
|
||||
return Err(InvalidRemoteEndpoint);
|
||||
}
|
||||
if path.starts_with('/') {
|
||||
return Ok(SshRepositoryPath::Absolute(path));
|
||||
}
|
||||
if let Some(tilde_path) = path.strip_prefix('~') {
|
||||
let user = tilde_path.split('/').next().unwrap_or_default();
|
||||
if !user.is_empty()
|
||||
&& !user
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return Err(InvalidRemoteEndpoint);
|
||||
}
|
||||
return Ok(SshRepositoryPath::Tilde(path));
|
||||
}
|
||||
if !allow_relative || path.starts_with('-') {
|
||||
return Err(InvalidRemoteEndpoint);
|
||||
}
|
||||
Ok(SshRepositoryPath::Relative(path))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct GitRemote {
|
||||
name: RemoteName,
|
||||
url: Url,
|
||||
server_id: ServerId,
|
||||
application_id: ApplicationId,
|
||||
url: String,
|
||||
endpoint: RemoteEndpoint,
|
||||
credentials: GitRemoteCredentials,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
enum GitRemoteCredentials {
|
||||
Https {
|
||||
server_id: ServerId,
|
||||
application_id: ApplicationId,
|
||||
},
|
||||
Ssh,
|
||||
}
|
||||
|
||||
impl GitRemote {
|
||||
@@ -894,8 +1135,18 @@ impl GitRemote {
|
||||
let mut remotes = validate_remotes(vec![RawGitRemote {
|
||||
name: name.into(),
|
||||
url: url.into(),
|
||||
server_id: server_id.into(),
|
||||
application_id: application_id.into(),
|
||||
server_id: Some(server_id.into()),
|
||||
application_id: Some(application_id.into()),
|
||||
}])?;
|
||||
Ok(remotes.remove(0))
|
||||
}
|
||||
|
||||
pub fn ssh(name: impl Into<String>, url: impl Into<String>) -> Result<Self, ConfigError> {
|
||||
let mut remotes = validate_remotes(vec![RawGitRemote {
|
||||
name: name.into(),
|
||||
url: url.into(),
|
||||
server_id: None,
|
||||
application_id: None,
|
||||
}])?;
|
||||
Ok(remotes.remove(0))
|
||||
}
|
||||
@@ -904,16 +1155,22 @@ impl GitRemote {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn url(&self) -> &Url {
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
pub fn server_id(&self) -> &ServerId {
|
||||
&self.server_id
|
||||
pub const fn endpoint(&self) -> &RemoteEndpoint {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
pub fn application_id(&self) -> &ApplicationId {
|
||||
&self.application_id
|
||||
pub const fn https_credentials(&self) -> Option<(&ServerId, &ApplicationId)> {
|
||||
match &self.credentials {
|
||||
GitRemoteCredentials::Https {
|
||||
server_id,
|
||||
application_id,
|
||||
} => Some((server_id, application_id)),
|
||||
GitRemoteCredentials::Ssh => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,7 +1324,7 @@ impl fmt::Display for ConfigError {
|
||||
}
|
||||
Self::InvalidRemoteUrl { name } => write!(
|
||||
formatter,
|
||||
"Git remote {name} must be an HTTPS URL without embedded credentials, query, or fragment"
|
||||
"Git remote {name} must be an allowed HTTPS or SSH endpoint without embedded credentials, query, or fragment"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1153,8 +1410,8 @@ struct RawGit {
|
||||
struct RawGitRemote {
|
||||
name: String,
|
||||
url: String,
|
||||
server_id: String,
|
||||
application_id: String,
|
||||
server_id: Option<String>,
|
||||
application_id: Option<String>,
|
||||
}
|
||||
|
||||
fn validate_config(
|
||||
@@ -1413,41 +1670,52 @@ fn validate_remotes(raw: Vec<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigErro
|
||||
let mut remotes = Vec::with_capacity(raw.len());
|
||||
for remote in raw {
|
||||
let name = RemoteName(validate_identifier("git.remotes.name", remote.name)?);
|
||||
let server_id = ServerId(validate_identifier(
|
||||
"git.remotes.server_id",
|
||||
remote.server_id,
|
||||
)?);
|
||||
let application_id = ApplicationId(validate_identifier(
|
||||
"git.remotes.application_id",
|
||||
remote.application_id,
|
||||
)?);
|
||||
if !names.insert(name.clone()) {
|
||||
return Err(ConfigError::DuplicateRemote {
|
||||
name: name.0.clone(),
|
||||
});
|
||||
}
|
||||
if !references.insert((server_id.clone(), application_id.clone())) {
|
||||
return Err(ConfigError::DuplicateCredentialReference);
|
||||
}
|
||||
let url = Url::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl {
|
||||
name: name.0.clone(),
|
||||
})?;
|
||||
if url.scheme() != "https"
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(ConfigError::InvalidRemoteUrl {
|
||||
let endpoint =
|
||||
RemoteEndpoint::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl {
|
||||
name: name.0.clone(),
|
||||
});
|
||||
}
|
||||
})?;
|
||||
let credentials = match (&endpoint, remote.server_id, remote.application_id) {
|
||||
(RemoteEndpoint::Https(_), Some(server_id), Some(application_id)) => {
|
||||
let server_id = ServerId(validate_identifier("git.remotes.server_id", server_id)?);
|
||||
let application_id = ApplicationId(validate_identifier(
|
||||
"git.remotes.application_id",
|
||||
application_id,
|
||||
)?);
|
||||
if !references.insert((server_id.clone(), application_id.clone())) {
|
||||
return Err(ConfigError::DuplicateCredentialReference);
|
||||
}
|
||||
GitRemoteCredentials::Https {
|
||||
server_id,
|
||||
application_id,
|
||||
}
|
||||
}
|
||||
(RemoteEndpoint::Https(_), None, _) => {
|
||||
return Err(ConfigError::MissingField {
|
||||
field: "git.remotes.server_id",
|
||||
});
|
||||
}
|
||||
(RemoteEndpoint::Https(_), _, None) => {
|
||||
return Err(ConfigError::MissingField {
|
||||
field: "git.remotes.application_id",
|
||||
});
|
||||
}
|
||||
(RemoteEndpoint::Ssh(_), None, None) => GitRemoteCredentials::Ssh,
|
||||
(RemoteEndpoint::Ssh(_), _, _) => {
|
||||
return Err(ConfigError::InvalidField {
|
||||
field: "git.remotes.https_credentials",
|
||||
});
|
||||
}
|
||||
};
|
||||
remotes.push(GitRemote {
|
||||
name,
|
||||
url,
|
||||
server_id,
|
||||
application_id,
|
||||
url: remote.url,
|
||||
endpoint,
|
||||
credentials,
|
||||
});
|
||||
}
|
||||
Ok(remotes)
|
||||
|
||||
Reference in New Issue
Block a user