Add typed SSH remote endpoints

This commit is contained in:
hugo committed 2026-08-25 19:12:34 +02:00
1 parent f0d6a04be1
commit f636f3b551
16 files changed
+1712 -310

No files matched your search

+65 -34
View File
@@ -24,7 +24,7 @@ use sha1::{Digest as _, Sha1};
use zeroize::Zeroize as _;
use crate::{
config::{ApplicationId, GitRemote, ServerId},
config::{ApplicationId, GitRemote, RemoteEndpoint, RemoteTransport, ServerId},
crypto::{KeyHandle, KeyStore, SecretProvider},
mutation::{TreeCommit, TreeCommitError, TreeCommitter},
recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
@@ -384,6 +384,9 @@ pub enum GitError {
DirtyWorktree,
InvalidRemoteName,
ForbiddenRemoteUrl,
UnsupportedRemoteTransport {
transport: RemoteTransport,
},
RemoteNotFound {
name: String,
},
@@ -429,7 +432,13 @@ impl fmt::Display for GitError {
Self::DirtyWorktree => formatter.write_str("the worktree has uncommitted changes"),
Self::InvalidRemoteName => formatter.write_str("the Git remote name is invalid"),
Self::ForbiddenRemoteUrl => {
formatter.write_str("Git remotes must use credential-free HTTPS URLs")
formatter.write_str("the Git remote URL is invalid or forbidden")
}
Self::UnsupportedRemoteTransport { transport } => {
write!(
formatter,
"this build does not support {transport} Git remotes"
)
}
Self::RemoteNotFound { name } => write!(formatter, "Git remote not found: {name}"),
Self::CredentialsUnavailable => {
@@ -861,7 +870,7 @@ impl GitRepository {
control: &GitOperationControl,
) -> Result<Vec<String>, GitError> {
control.report(GitProgressPhase::Validating)?;
validate_https_remote(configured.url().as_str())?;
require_https_remote(configured)?;
ensure_clone_parent(parent)?;
let temporary = private_temporary_directory(parent, "probe")?;
let result = (|| {
@@ -874,7 +883,7 @@ impl GitRepository {
)
.map_err(invalid)?;
let mut repository = Self::open(&store, identity)?;
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
repository.add_remote(configured.name().as_str(), configured.url())?;
repository.fetch_with_transport_controlled(
configured,
credentials,
@@ -951,7 +960,7 @@ impl GitRepository {
control: &GitOperationControl,
) -> Result<Self, GitError> {
control.report(GitProgressPhase::Validating)?;
validate_https_remote(configured.url().as_str())?;
require_https_remote(configured)?;
if let Some(branch) = branch {
validate_remote_name(branch)?;
}
@@ -984,7 +993,7 @@ impl GitRepository {
)
.map_err(invalid)?;
let mut repository = Self::open(&store, identity.clone())?;
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
repository.add_remote(configured.name().as_str(), configured.url())?;
repository.pull_with_transport_controlled(
configured,
branch,
@@ -1167,13 +1176,15 @@ impl GitRepository {
name: name.to_owned(),
})?;
let text = url.to_bstring().to_str_lossy().into_owned();
validate_https_remote(&text)?;
RemoteEndpoint::parse(&text).map_err(|_| GitError::ForbiddenRemoteUrl)?;
Ok(text)
}
pub fn add_remote(&mut self, name: &str, url: &str) -> Result<(), GitError> {
validate_remote_name(name)?;
validate_https_remote(url)?;
ensure_remote_transport_available(
&RemoteEndpoint::parse(url).map_err(|_| GitError::ForbiddenRemoteUrl)?,
)?;
if self.remotes().iter().any(|existing| existing == name) {
return Err(GitError::InvalidRepository(format!(
"remote {name} already exists"
@@ -1197,7 +1208,9 @@ impl GitRepository {
pub fn set_remote_url(&mut self, name: &str, url: &str) -> Result<(), GitError> {
validate_remote_name(name)?;
validate_https_remote(url)?;
ensure_remote_transport_available(
&RemoteEndpoint::parse(url).map_err(|_| GitError::ForbiddenRemoteUrl)?,
)?;
if !self.remotes().iter().any(|existing| existing == name) {
return Err(GitError::RemoteNotFound {
name: name.to_owned(),
@@ -1248,7 +1261,9 @@ impl GitRepository {
));
}
validate_remote_name(&parts[1..parts.len() - 1].join("."))?;
validate_https_remote(value)?;
ensure_remote_transport_available(
&RemoteEndpoint::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?,
)?;
}
if value.contains(['\n', '\r', '\0']) {
return Err(GitError::InvalidRepository(
@@ -1293,12 +1308,12 @@ impl GitRepository {
control.checkpoint(GitProgressPhase::Validating)?;
let name = configured.name().as_str();
let actual_url = self.remote_url(name)?;
if actual_url != configured.url().as_str() {
if !same_remote_endpoint(&actual_url, configured.url())? {
return Err(GitError::ForbiddenRemoteUrl);
}
let (_, server_id, application_id) = require_https_remote(configured)?;
control.checkpoint(GitProgressPhase::Authenticating)?;
let credential =
credentials.credential(configured.server_id(), configured.application_id())?;
let credential = credentials.credential(server_id, application_id)?;
let received_pack = transport.fetch_controlled(self, configured, &credential, control)?;
Ok(FetchOutcome {
remote: name.to_owned(),
@@ -1333,7 +1348,7 @@ impl GitRepository {
.map_err(|_| GitError::CredentialsUnavailable)?
.to_owned();
let username = credential.username().to_owned();
let expected_origin = configured.url().clone();
let expected_origin = require_https_remote(configured)?.0.clone();
let remote = self
.repository
.find_fetch_remote(Some(name.into()))
@@ -1576,10 +1591,10 @@ impl GitRepository {
}
let name = configured.name().as_str();
let actual_url = self.remote_url(name)?;
if actual_url != configured.url().as_str() {
if !same_remote_endpoint(&actual_url, configured.url())? {
return Err(GitError::ForbiddenRemoteUrl);
}
let url = validate_https_remote(&actual_url)?;
let (url, server_id, application_id) = require_https_remote(configured)?;
let branch = branch.map_or_else(
|| self.current_branch(),
|branch| {
@@ -1594,10 +1609,9 @@ impl GitRepository {
.map_err(|_| GitError::UnbornHead)?
.detach();
control.checkpoint(GitProgressPhase::Authenticating)?;
let credential =
credentials.credential(configured.server_id(), configured.application_id())?;
let credential = credentials.credential(server_id, application_id)?;
let advertisement =
transport.advertise_receive_pack_controlled(&url, &credential, control)?;
transport.advertise_receive_pack_controlled(url, &credential, control)?;
let advertised = parse_receive_pack_advertisement(&advertisement)?;
let old = advertised.refs.get(&reference).copied();
if let Some(old) = old {
@@ -1635,7 +1649,7 @@ impl GitRepository {
let mut request = encode_pkt_line(command.as_bytes())?;
request.extend_from_slice(b"0000");
request.extend_from_slice(&pack);
let response = transport.receive_pack_controlled(&url, &credential, request, control)?;
let response = transport.receive_pack_controlled(url, &credential, request, control)?;
parse_receive_pack_result(&response, &reference)?;
self.update_remote_tracking(name, &branch, new)?;
Ok(PushOutcome {
@@ -1706,7 +1720,7 @@ impl GitRepository {
let name = configured.name().as_str();
let actual_url = if self.remotes().iter().any(|remote| remote == name) {
let actual = self.remote_url(name)?;
if actual != configured.url().as_str() {
if !same_remote_endpoint(&actual, configured.url())? {
return Err(GitError::ForbiddenRemoteUrl);
}
actual
@@ -1756,7 +1770,7 @@ impl GitRepository {
let status = self.status()?;
let name = configured.name().as_str();
let actual_url = self.remote_url(name)?;
if actual_url != configured.url().as_str() {
if !same_remote_endpoint(&actual_url, configured.url())? {
return Err(GitError::ForbiddenRemoteUrl);
}
let local_id = self
@@ -3375,18 +3389,34 @@ fn private_temporary_directory(parent: &Path, purpose: &str) -> Result<PathBuf,
.ok_or_else(|| io("create private Git directory", parent))
}
fn validate_https_remote(value: &str) -> Result<url::Url, GitError> {
let parsed = url::Url::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?;
if parsed.scheme() != "https"
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err(GitError::ForbiddenRemoteUrl);
fn require_https_remote(
remote: &GitRemote,
) -> Result<(&url::Url, &ServerId, &ApplicationId), GitError> {
let url = remote
.endpoint()
.as_https()
.ok_or(GitError::UnsupportedRemoteTransport {
transport: RemoteTransport::Ssh,
})?;
let (server_id, application_id) = remote
.https_credentials()
.ok_or(GitError::ForbiddenRemoteUrl)?;
Ok((url, server_id, application_id))
}
fn same_remote_endpoint(actual: &str, configured: &str) -> Result<bool, GitError> {
let actual = RemoteEndpoint::parse(actual).map_err(|_| GitError::ForbiddenRemoteUrl)?;
let configured = RemoteEndpoint::parse(configured).map_err(|_| GitError::ForbiddenRemoteUrl)?;
Ok(actual == configured)
}
fn ensure_remote_transport_available(endpoint: &RemoteEndpoint) -> Result<(), GitError> {
if endpoint.transport() == RemoteTransport::Ssh && !cfg!(feature = "ssh") {
return Err(GitError::UnsupportedRemoteTransport {
transport: RemoteTransport::Ssh,
});
}
Ok(parsed)
Ok(())
}
fn same_https_origin(expected: &url::Url, requested: &url::Url) -> bool {
@@ -3485,7 +3515,8 @@ fn validate_local_config_security(config: &gix_config::File) -> Result<(), GitEr
));
}
if let Ok(url) = config.raw_value_by("remote", Some(name.into()), "url") {
validate_https_remote(url.to_str().map_err(|_| GitError::ForbiddenRemoteUrl)?)?;
RemoteEndpoint::parse(url.to_str().map_err(|_| GitError::ForbiddenRemoteUrl)?)
.map_err(|_| GitError::ForbiddenRemoteUrl)?;
}
}
}