Add typed SSH remote endpoints

This commit is contained in:
2026-08-25 19:12:34 +02:00
parent f0d6a04be1
commit f636f3b551
16 changed files with 1712 additions and 310 deletions

View File

@@ -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)

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)?;
}
}
}

View File

@@ -593,15 +593,7 @@ impl MobileAuthentication {
drop(status);
let repository_title = remote.map_or_else(
|| "Local Password Store".to_owned(),
|remote| {
remote
.url()
.path_segments()
.and_then(Iterator::last)
.unwrap_or("Password Store")
.trim_end_matches(".git")
.to_owned()
},
remote_repository_title,
);
Ok(MobilePreferences {
sync_configured: remote.is_some(),
@@ -611,12 +603,23 @@ impl MobileAuthentication {
|remote| remote.url().to_string(),
),
server_title: remote
.and_then(|remote| remote.url().host_str())
.and_then(|remote| {
remote
.endpoint()
.as_https()
.and_then(url::Url::host_str)
.or_else(|| remote.endpoint().as_ssh().map(|ssh| ssh.host()))
})
.unwrap_or("Git Sync Not Configured")
.to_owned(),
server_identity: remote.map_or_else(
|| "Add an HTTPS remote when you want to sync".to_owned(),
|remote| remote.server_id().as_str().to_owned(),
|remote| {
remote.https_credentials().map_or_else(
|| "SSH identity and host trust".to_owned(),
|(server_id, _)| server_id.as_str().to_owned(),
)
},
),
application_account: remote.map(application_account).transpose()?.flatten(),
default_key_title: key
@@ -673,7 +676,10 @@ impl MobileAuthentication {
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
let store = preference_secret_store()?;
store.unlock().map_err(preference_secret_error)?;
let result = store.delete_https_git_credential(remote.server_id(), remote.application_id());
let (server_id, application_id) = remote.https_credentials().ok_or_else(|| {
config_detail("The configured Git remote does not use HTTPS credentials.")
})?;
let result = store.delete_https_git_credential(server_id, application_id);
let lock_result = store.lock();
if !matches!(result, Ok(()) | Err(SecretStoreError::Missing)) {
return Err(preference_secret_error(result.expect_err("checked error")));
@@ -1558,9 +1564,12 @@ fn preference_secret_store() -> Result<NativeSecretStore, MobileAuthenticationEr
}
fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthenticationError> {
let Some((server_id, application_id)) = remote.https_credentials() else {
return Ok(None);
};
let store = preference_secret_store()?;
store.unlock().map_err(preference_secret_error)?;
let result = store.https_git_credential_account(remote.server_id(), remote.application_id());
let result = store.https_git_credential_account(server_id, application_id);
let lock_result = store.lock();
let account = match result {
Ok(account) => Some(account),
@@ -1572,6 +1581,26 @@ fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthe
.map(|()| account)
}
fn remote_repository_title(remote: &GitRemote) -> String {
let path = remote.endpoint().as_https().map_or_else(
|| {
remote
.endpoint()
.as_ssh()
.map(|ssh| ssh.path().as_str())
.unwrap_or_default()
},
|url| url.path(),
);
path.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|name| !name.is_empty())
.unwrap_or("Password Store")
.trim_end_matches(".git")
.to_owned()
}
fn preference_secret_error(error: SecretStoreError) -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::SecureStorage,

View File

@@ -147,7 +147,7 @@ impl MobileOnboardingOperation {
let actual = git
.remote_url(request.remote.name().as_str())
.map_err(MobileOnboardingError::from_git)?;
if actual != request.remote.url().as_str()
if actual != request.remote.url()
|| git.current_branch().ok().as_deref() != Some(branch)
{
return Err(MobileOnboardingError::different_existing_clone());
@@ -219,11 +219,8 @@ impl MobileOnboardingOperation {
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
let mut git = GitRepository::open(&repository, config.git_identity().clone())
.map_err(MobileOnboardingError::from_git)?;
git.add_remote(
request.remote.name().as_str(),
request.remote.url().as_str(),
)
.map_err(MobileOnboardingError::from_git)?;
git.add_remote(request.remote.name().as_str(), request.remote.url())
.map_err(MobileOnboardingError::from_git)?;
if let Err(error) = config.update_mobile_remote(Some(&request.remote)) {
let _ = git.remove_remote(request.remote.name().as_str());
return Err(MobileOnboardingError::from_config(error));
@@ -481,7 +478,10 @@ impl GitCredentialProvider for MobileOnboardingRequest {
server: &crate::config::ServerId,
application: &crate::config::ApplicationId,
) -> Result<GitCredential, GitError> {
if server != self.remote.server_id() || application != self.remote.application_id() {
let Some((expected_server, expected_application)) = self.remote.https_credentials() else {
return Err(GitError::CredentialsUnavailable);
};
if server != expected_server || application != expected_application {
return Err(GitError::CredentialsUnavailable);
}
GitCredential::new(self.account.clone(), self.token.expose().to_vec())
@@ -985,8 +985,12 @@ fn default_key(
}
fn store_application_token(request: &MobileOnboardingRequest) -> Result<(), MobileOnboardingError> {
let (server_id, application_id) = request
.remote
.https_credentials()
.ok_or_else(MobileOnboardingError::token_configuration)?;
let credential = request
.credential(request.remote.server_id(), request.remote.application_id())
.credential(server_id, application_id)
.map_err(MobileOnboardingError::from_git)?;
store_git_credential(&request.remote, &credential)
}
@@ -995,6 +999,9 @@ fn store_git_credential(
remote: &GitRemote,
credential: &GitCredential,
) -> Result<(), MobileOnboardingError> {
let (server_id, application_id) = remote
.https_credentials()
.ok_or_else(MobileOnboardingError::token_configuration)?;
let store = NativeSecretStore::system(
SecretCachePolicy::Disabled,
SecretProtectionPolicy::device_unlocked(),
@@ -1003,8 +1010,8 @@ fn store_git_credential(
store.unlock().map_err(MobileOnboardingError::from_secret)?;
store
.store_https_git_credential(
remote.server_id(),
remote.application_id(),
server_id,
application_id,
credential.username(),
SecretBytes::new(credential.password().to_vec()),
)
@@ -1069,7 +1076,7 @@ mod tests {
)
.expect("valid request");
assert_eq!(
request.remote().url().as_str(),
request.remote().url(),
"https://example.test/gitea/team/passwords.git"
);
assert!(!format!("{request:?}").contains("DO-NOT-RENDER"));