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

@@ -7,8 +7,11 @@ derived from a password-store repository. This includes filesystem layout,
`.gpg-id` handling, GPG-compatible encryption and key handling, Git, `.gpg-id` handling, GPG-compatible encryption and key handling, Git,
synchronization, conflict handling, entry parsing, password generation, OTP, synchronization, conflict handling, entry parsing, password generation, OTP,
QR payloads, server/application identities, HTTPS Git credentials, and QR payloads, server/application identities, HTTPS Git credentials, and
secure-secret-storage orchestration. Git remotes are HTTPS-only; reject other secure-secret-storage orchestration. Git remotes are typed HTTPS or optional
schemes before entering the Git transport. SSH endpoints; reject local, helper, executable, and unknown transports before
entering Git transport code. Builds without the `ssh` feature must still parse
SSH endpoints and return a typed unsupported-transport error before connection
or repository mutation.
The CLI, Ratatui, Iced, Swift, SwiftUI, AutoFill, and watchOS code may collect The CLI, Ratatui, Iced, Swift, SwiftUI, AutoFill, and watchOS code may collect
input, invoke the Rust API, and present Rust-provided state. They must not input, invoke the Rust API, and present Rust-provided state. They must not

1076
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,7 @@ keyring-core = "1.0"
keepass = "0.13.20" keepass = "0.13.20"
muda = "0.19" muda = "0.19"
pgp = { version = "0.20", default-features = false } pgp = { version = "0.20", default-features = false }
percent-encoding = "2.3"
qrcode = { version = "0.14", default-features = false } qrcode = { version = "0.14", default-features = false }
rand = "0.8" rand = "0.8"
regex = "1.13" regex = "1.13"
@@ -51,6 +52,7 @@ reqwest = { version = "0.13", default-features = false, features = ["blocking",
rfd = { version = "0.17", default-features = false } rfd = { version = "0.17", default-features = false }
rqrr = { version = "0.10", default-features = false } rqrr = { version = "0.10", default-features = false }
rpassword = "7.5" rpassword = "7.5"
russh = { version = "0.63.1", default-features = false, features = ["ring"] }
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] }
security-framework = "3.7" security-framework = "3.7"
secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] } secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] }
@@ -61,6 +63,7 @@ sha2 = "0.10"
shlex = "1.3" shlex = "1.3"
simple-file-manifest = "0.11" simple-file-manifest = "0.11"
toml = "0.9" toml = "0.9"
tokio = { version = "1.53.1", default-features = false, features = ["io-util", "net", "rt-multi-thread", "sync", "time"] }
uniffi = "0.32" uniffi = "0.32"
url = { version = "2.5", default-features = false } url = { version = "2.5", default-features = false }
windows-native-keyring-store = { version = "1.1", default-features = false } windows-native-keyring-store = { version = "1.1", default-features = false }

View File

@@ -12,8 +12,10 @@ and the first-party `pass` command behavior. `pass-otp` compatibility adds
The compatibility target includes init, list/show, find/grep, insert/edit, The compatibility target includes init, list/show, find/grep, insert/edit,
generate, remove, move/copy, and Git-backed commit and synchronization flows. generate, remove, move/copy, and Git-backed commit and synchronization flows.
Remote synchronization is HTTPS-only and uses server/application credentials Remote endpoints are typed in the storage crate as credential-free HTTPS or
kept in the operating system's secure store. feature-gated SSH. HTTPS synchronization uses server/application credentials
kept in the operating system's secure store; SSH transport dependencies and
runtime are compiled only with the optional `ssh` Cargo feature.
The central `ironstorage` Rust crate owns repository access, Git, The central `ironstorage` Rust crate owns repository access, Git,
GPG-compatible encryption and key handling, entries, OTP, synchronization, GPG-compatible encryption and key handling, entries, OTP, synchronization,
@@ -24,7 +26,7 @@ and `gpg`—are forbidden; compatibility is implemented in Rust.
IronStorage is MIT licensed. Dependency licenses and the completed packaging IronStorage is MIT licensed. Dependency licenses and the completed packaging
review are tracked in [`DEPENDENCIES.md`](DEPENDENCIES.md). review are tracked in [`DEPENDENCIES.md`](DEPENDENCIES.md).
The shared TOML schema, path rules, editor precedence, and HTTPS remote format The shared TOML schema, path rules, editor precedence, and Git remote formats
are documented in [`docs/configuration.md`](docs/configuration.md). are documented in [`docs/configuration.md`](docs/configuration.md).
Embedded Git, HTTPS synchronization, merge behavior, and commit signing are Embedded Git, HTTPS synchronization, merge behavior, and commit signing are
documented in [`docs/git-synchronization.md`](docs/git-synchronization.md). documented in [`docs/git-synchronization.md`](docs/git-synchronization.md).

View File

@@ -1939,15 +1939,16 @@ mod tests {
assert!(stderr.is_empty()); assert!(stderr.is_empty());
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
let (server_id, application_id) = remote.https_credentials().expect("HTTPS remote");
secrets.create( secrets.create(
&SecretReference::https_git_credential( &SecretReference::https_git_credential(
remote.server_id().as_str(), server_id.as_str(),
remote.application_id().as_str(), application_id.as_str(),
"fixture-account", "fixture-account",
)?, )?,
SecretBytes::new(b"fixture-token".to_vec()), SecretBytes::new(b"fixture-token".to_vec()),
)?; )?;
let credential = secrets.credential(remote.server_id(), remote.application_id())?; let credential = secrets.credential(server_id, application_id)?;
assert_eq!(credential.username(), "fixture-account"); assert_eq!(credential.username(), "fixture-account");
assert_eq!(credential.password(), b"fixture-token"); assert_eq!(credential.password(), b"fixture-token");
Ok(()) Ok(())

View File

@@ -7601,7 +7601,7 @@ mod tests {
assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl)); assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl));
assert_eq!( assert_eq!(
error.to_string(), error.to_string(),
"Git remotes must use credential-free HTTPS URLs" "the Git remote URL is invalid or forbidden"
); );
assert!(DesktopGitRequest::Pull.requires_authentication()); assert!(DesktopGitRequest::Pull.requires_authentication());

View File

@@ -24,6 +24,7 @@ full = [
"dep:keepass", "dep:keepass",
"dep:keyring-core", "dep:keyring-core",
"dep:pgp", "dep:pgp",
"dep:percent-encoding",
"dep:qrcode", "dep:qrcode",
"dep:rand", "dep:rand",
"dep:regex", "dep:regex",
@@ -38,6 +39,7 @@ full = [
"dep:windows-native-keyring-store", "dep:windows-native-keyring-store",
"dep:zbus-secret-service-keyring-store", "dep:zbus-secret-service-keyring-store",
] ]
ssh = ["dep:russh", "dep:tokio"]
watch = [] watch = []
[dependencies] [dependencies]
@@ -54,16 +56,19 @@ image = { workspace = true, optional = true }
keyring-core = { workspace = true, optional = true } keyring-core = { workspace = true, optional = true }
keepass = { workspace = true, optional = true } keepass = { workspace = true, optional = true }
pgp = { workspace = true, optional = true } pgp = { workspace = true, optional = true }
percent-encoding = { workspace = true, optional = true }
qrcode = { workspace = true, optional = true } qrcode = { workspace = true, optional = true }
rand = { workspace = true, optional = true } rand = { workspace = true, optional = true }
regex = { workspace = true, optional = true } regex = { workspace = true, optional = true }
reqwest = { workspace = true, optional = true } reqwest = { workspace = true, optional = true }
rqrr = { workspace = true, optional = true } rqrr = { workspace = true, optional = true }
russh = { workspace = true, optional = true }
serde = { workspace = true, optional = true } serde = { workspace = true, optional = true }
sha1.workspace = true sha1.workspace = true
sha2.workspace = true sha2.workspace = true
shlex = { workspace = true, optional = true } shlex = { workspace = true, optional = true }
toml = { workspace = true, optional = true } toml = { workspace = true, optional = true }
tokio = { workspace = true, optional = true }
url = { workspace = true, optional = true } url = { workspace = true, optional = true }
zeroize.workspace = true zeroize.workspace = true

View File

@@ -420,26 +420,9 @@ impl Config {
.ok_or(ConfigError::InvalidField { field: "git" })?; .ok_or(ConfigError::InvalidField { field: "git" })?;
match remote { match remote {
Some(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( git.insert(
"remotes".to_owned(), "remotes".to_owned(),
toml::Value::Array(vec![toml::Value::Table(configured)]), toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote))]),
); );
} }
None => { None => {
@@ -511,27 +494,10 @@ impl Config {
toml::Value::String(path_text(key_material, "key_material")?), toml::Value::String(path_text(key_material, "key_material")?),
); );
if let Some(remote) = remote { 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(); let mut git = toml::Table::new();
git.insert( git.insert(
"remotes".to_owned(), "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)); 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. /// Deterministic path context for configuration loading.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigLoader { pub struct ConfigLoader {
@@ -876,12 +865,264 @@ identifier_type!(RemoteName);
identifier_type!(ServerId); identifier_type!(ServerId);
identifier_type!(ApplicationId); 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)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitRemote { pub struct GitRemote {
name: RemoteName, name: RemoteName,
url: Url, url: String,
server_id: ServerId, endpoint: RemoteEndpoint,
application_id: ApplicationId, credentials: GitRemoteCredentials,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum GitRemoteCredentials {
Https {
server_id: ServerId,
application_id: ApplicationId,
},
Ssh,
} }
impl GitRemote { impl GitRemote {
@@ -894,8 +1135,18 @@ impl GitRemote {
let mut remotes = validate_remotes(vec![RawGitRemote { let mut remotes = validate_remotes(vec![RawGitRemote {
name: name.into(), name: name.into(),
url: url.into(), url: url.into(),
server_id: server_id.into(), server_id: Some(server_id.into()),
application_id: application_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)) Ok(remotes.remove(0))
} }
@@ -904,16 +1155,22 @@ impl GitRemote {
&self.name &self.name
} }
pub fn url(&self) -> &Url { pub fn url(&self) -> &str {
&self.url &self.url
} }
pub fn server_id(&self) -> &ServerId { pub const fn endpoint(&self) -> &RemoteEndpoint {
&self.server_id &self.endpoint
} }
pub fn application_id(&self) -> &ApplicationId { pub const fn https_credentials(&self) -> Option<(&ServerId, &ApplicationId)> {
&self.application_id 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!( Self::InvalidRemoteUrl { name } => write!(
formatter, 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 { struct RawGitRemote {
name: String, name: String,
url: String, url: String,
server_id: String, server_id: Option<String>,
application_id: String, application_id: Option<String>,
} }
fn validate_config( 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()); let mut remotes = Vec::with_capacity(raw.len());
for remote in raw { for remote in raw {
let name = RemoteName(validate_identifier("git.remotes.name", remote.name)?); 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()) { if !names.insert(name.clone()) {
return Err(ConfigError::DuplicateRemote { return Err(ConfigError::DuplicateRemote {
name: name.0.clone(), name: name.0.clone(),
}); });
} }
if !references.insert((server_id.clone(), application_id.clone())) { let endpoint =
return Err(ConfigError::DuplicateCredentialReference); RemoteEndpoint::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl {
}
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 {
name: name.0.clone(), 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 { remotes.push(GitRemote {
name, name,
url, url: remote.url,
server_id, endpoint,
application_id, credentials,
}); });
} }
Ok(remotes) Ok(remotes)

View File

@@ -24,7 +24,7 @@ use sha1::{Digest as _, Sha1};
use zeroize::Zeroize as _; use zeroize::Zeroize as _;
use crate::{ use crate::{
config::{ApplicationId, GitRemote, ServerId}, config::{ApplicationId, GitRemote, RemoteEndpoint, RemoteTransport, ServerId},
crypto::{KeyHandle, KeyStore, SecretProvider}, crypto::{KeyHandle, KeyStore, SecretProvider},
mutation::{TreeCommit, TreeCommitError, TreeCommitter}, mutation::{TreeCommit, TreeCommitError, TreeCommitter},
recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter}, recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
@@ -384,6 +384,9 @@ pub enum GitError {
DirtyWorktree, DirtyWorktree,
InvalidRemoteName, InvalidRemoteName,
ForbiddenRemoteUrl, ForbiddenRemoteUrl,
UnsupportedRemoteTransport {
transport: RemoteTransport,
},
RemoteNotFound { RemoteNotFound {
name: String, name: String,
}, },
@@ -429,7 +432,13 @@ impl fmt::Display for GitError {
Self::DirtyWorktree => formatter.write_str("the worktree has uncommitted changes"), Self::DirtyWorktree => formatter.write_str("the worktree has uncommitted changes"),
Self::InvalidRemoteName => formatter.write_str("the Git remote name is invalid"), Self::InvalidRemoteName => formatter.write_str("the Git remote name is invalid"),
Self::ForbiddenRemoteUrl => { 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::RemoteNotFound { name } => write!(formatter, "Git remote not found: {name}"),
Self::CredentialsUnavailable => { Self::CredentialsUnavailable => {
@@ -861,7 +870,7 @@ impl GitRepository {
control: &GitOperationControl, control: &GitOperationControl,
) -> Result<Vec<String>, GitError> { ) -> Result<Vec<String>, GitError> {
control.report(GitProgressPhase::Validating)?; control.report(GitProgressPhase::Validating)?;
validate_https_remote(configured.url().as_str())?; require_https_remote(configured)?;
ensure_clone_parent(parent)?; ensure_clone_parent(parent)?;
let temporary = private_temporary_directory(parent, "probe")?; let temporary = private_temporary_directory(parent, "probe")?;
let result = (|| { let result = (|| {
@@ -874,7 +883,7 @@ impl GitRepository {
) )
.map_err(invalid)?; .map_err(invalid)?;
let mut repository = Self::open(&store, identity)?; 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( repository.fetch_with_transport_controlled(
configured, configured,
credentials, credentials,
@@ -951,7 +960,7 @@ impl GitRepository {
control: &GitOperationControl, control: &GitOperationControl,
) -> Result<Self, GitError> { ) -> Result<Self, GitError> {
control.report(GitProgressPhase::Validating)?; control.report(GitProgressPhase::Validating)?;
validate_https_remote(configured.url().as_str())?; require_https_remote(configured)?;
if let Some(branch) = branch { if let Some(branch) = branch {
validate_remote_name(branch)?; validate_remote_name(branch)?;
} }
@@ -984,7 +993,7 @@ impl GitRepository {
) )
.map_err(invalid)?; .map_err(invalid)?;
let mut repository = Self::open(&store, identity.clone())?; 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( repository.pull_with_transport_controlled(
configured, configured,
branch, branch,
@@ -1167,13 +1176,15 @@ impl GitRepository {
name: name.to_owned(), name: name.to_owned(),
})?; })?;
let text = url.to_bstring().to_str_lossy().into_owned(); let text = url.to_bstring().to_str_lossy().into_owned();
validate_https_remote(&text)?; RemoteEndpoint::parse(&text).map_err(|_| GitError::ForbiddenRemoteUrl)?;
Ok(text) Ok(text)
} }
pub fn add_remote(&mut self, name: &str, url: &str) -> Result<(), GitError> { pub fn add_remote(&mut self, name: &str, url: &str) -> Result<(), GitError> {
validate_remote_name(name)?; 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) { if self.remotes().iter().any(|existing| existing == name) {
return Err(GitError::InvalidRepository(format!( return Err(GitError::InvalidRepository(format!(
"remote {name} already exists" "remote {name} already exists"
@@ -1197,7 +1208,9 @@ impl GitRepository {
pub fn set_remote_url(&mut self, name: &str, url: &str) -> Result<(), GitError> { pub fn set_remote_url(&mut self, name: &str, url: &str) -> Result<(), GitError> {
validate_remote_name(name)?; 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) { if !self.remotes().iter().any(|existing| existing == name) {
return Err(GitError::RemoteNotFound { return Err(GitError::RemoteNotFound {
name: name.to_owned(), name: name.to_owned(),
@@ -1248,7 +1261,9 @@ impl GitRepository {
)); ));
} }
validate_remote_name(&parts[1..parts.len() - 1].join("."))?; 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']) { if value.contains(['\n', '\r', '\0']) {
return Err(GitError::InvalidRepository( return Err(GitError::InvalidRepository(
@@ -1293,12 +1308,12 @@ impl GitRepository {
control.checkpoint(GitProgressPhase::Validating)?; control.checkpoint(GitProgressPhase::Validating)?;
let name = configured.name().as_str(); let name = configured.name().as_str();
let actual_url = self.remote_url(name)?; 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); return Err(GitError::ForbiddenRemoteUrl);
} }
let (_, server_id, application_id) = require_https_remote(configured)?;
control.checkpoint(GitProgressPhase::Authenticating)?; control.checkpoint(GitProgressPhase::Authenticating)?;
let credential = let credential = credentials.credential(server_id, application_id)?;
credentials.credential(configured.server_id(), configured.application_id())?;
let received_pack = transport.fetch_controlled(self, configured, &credential, control)?; let received_pack = transport.fetch_controlled(self, configured, &credential, control)?;
Ok(FetchOutcome { Ok(FetchOutcome {
remote: name.to_owned(), remote: name.to_owned(),
@@ -1333,7 +1348,7 @@ impl GitRepository {
.map_err(|_| GitError::CredentialsUnavailable)? .map_err(|_| GitError::CredentialsUnavailable)?
.to_owned(); .to_owned();
let username = credential.username().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 let remote = self
.repository .repository
.find_fetch_remote(Some(name.into())) .find_fetch_remote(Some(name.into()))
@@ -1576,10 +1591,10 @@ impl GitRepository {
} }
let name = configured.name().as_str(); let name = configured.name().as_str();
let actual_url = self.remote_url(name)?; 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); 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( let branch = branch.map_or_else(
|| self.current_branch(), || self.current_branch(),
|branch| { |branch| {
@@ -1594,10 +1609,9 @@ impl GitRepository {
.map_err(|_| GitError::UnbornHead)? .map_err(|_| GitError::UnbornHead)?
.detach(); .detach();
control.checkpoint(GitProgressPhase::Authenticating)?; control.checkpoint(GitProgressPhase::Authenticating)?;
let credential = let credential = credentials.credential(server_id, application_id)?;
credentials.credential(configured.server_id(), configured.application_id())?;
let advertisement = 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 advertised = parse_receive_pack_advertisement(&advertisement)?;
let old = advertised.refs.get(&reference).copied(); let old = advertised.refs.get(&reference).copied();
if let Some(old) = old { if let Some(old) = old {
@@ -1635,7 +1649,7 @@ impl GitRepository {
let mut request = encode_pkt_line(command.as_bytes())?; let mut request = encode_pkt_line(command.as_bytes())?;
request.extend_from_slice(b"0000"); request.extend_from_slice(b"0000");
request.extend_from_slice(&pack); 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)?; parse_receive_pack_result(&response, &reference)?;
self.update_remote_tracking(name, &branch, new)?; self.update_remote_tracking(name, &branch, new)?;
Ok(PushOutcome { Ok(PushOutcome {
@@ -1706,7 +1720,7 @@ impl GitRepository {
let name = configured.name().as_str(); let name = configured.name().as_str();
let actual_url = if self.remotes().iter().any(|remote| remote == name) { let actual_url = if self.remotes().iter().any(|remote| remote == name) {
let actual = self.remote_url(name)?; let actual = self.remote_url(name)?;
if actual != configured.url().as_str() { if !same_remote_endpoint(&actual, configured.url())? {
return Err(GitError::ForbiddenRemoteUrl); return Err(GitError::ForbiddenRemoteUrl);
} }
actual actual
@@ -1756,7 +1770,7 @@ impl GitRepository {
let status = self.status()?; let status = self.status()?;
let name = configured.name().as_str(); let name = configured.name().as_str();
let actual_url = self.remote_url(name)?; 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); return Err(GitError::ForbiddenRemoteUrl);
} }
let local_id = self 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)) .ok_or_else(|| io("create private Git directory", parent))
} }
fn validate_https_remote(value: &str) -> Result<url::Url, GitError> { fn require_https_remote(
let parsed = url::Url::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?; remote: &GitRemote,
if parsed.scheme() != "https" ) -> Result<(&url::Url, &ServerId, &ApplicationId), GitError> {
|| parsed.host_str().is_none() let url = remote
|| !parsed.username().is_empty() .endpoint()
|| parsed.password().is_some() .as_https()
|| parsed.query().is_some() .ok_or(GitError::UnsupportedRemoteTransport {
|| parsed.fragment().is_some() transport: RemoteTransport::Ssh,
{ })?;
return Err(GitError::ForbiddenRemoteUrl); 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 { 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") { 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); drop(status);
let repository_title = remote.map_or_else( let repository_title = remote.map_or_else(
|| "Local Password Store".to_owned(), || "Local Password Store".to_owned(),
|remote| { remote_repository_title,
remote
.url()
.path_segments()
.and_then(Iterator::last)
.unwrap_or("Password Store")
.trim_end_matches(".git")
.to_owned()
},
); );
Ok(MobilePreferences { Ok(MobilePreferences {
sync_configured: remote.is_some(), sync_configured: remote.is_some(),
@@ -611,12 +603,23 @@ impl MobileAuthentication {
|remote| remote.url().to_string(), |remote| remote.url().to_string(),
), ),
server_title: remote 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") .unwrap_or("Git Sync Not Configured")
.to_owned(), .to_owned(),
server_identity: remote.map_or_else( server_identity: remote.map_or_else(
|| "Add an HTTPS remote when you want to sync".to_owned(), || "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(), application_account: remote.map(application_account).transpose()?.flatten(),
default_key_title: key default_key_title: key
@@ -673,7 +676,10 @@ impl MobileAuthentication {
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?; .ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
let store = preference_secret_store()?; let store = preference_secret_store()?;
store.unlock().map_err(preference_secret_error)?; 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(); let lock_result = store.lock();
if !matches!(result, Ok(()) | Err(SecretStoreError::Missing)) { if !matches!(result, Ok(()) | Err(SecretStoreError::Missing)) {
return Err(preference_secret_error(result.expect_err("checked error"))); 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> { 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()?; let store = preference_secret_store()?;
store.unlock().map_err(preference_secret_error)?; 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 lock_result = store.lock();
let account = match result { let account = match result {
Ok(account) => Some(account), Ok(account) => Some(account),
@@ -1572,6 +1581,26 @@ fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthe
.map(|()| account) .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 { fn preference_secret_error(error: SecretStoreError) -> MobileAuthenticationError {
MobileAuthenticationError::new( MobileAuthenticationError::new(
MobileAuthenticationErrorKind::SecureStorage, MobileAuthenticationErrorKind::SecureStorage,

View File

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

View File

@@ -7,7 +7,10 @@ use ironstorage::{
authentication::{ authentication::{
AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT, AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT,
}, },
config::{ConfigError, ConfigLoader, EditorSource, MobileAppearance}, config::{
ConfigError, ConfigLoader, EditorSource, MobileAppearance, RemoteEndpoint, RemoteTransport,
SshRepositoryPath,
},
desktop::DesktopStorage, desktop::DesktopStorage,
git::GitIdentity, git::GitIdentity,
mobile::MobileTab, mobile::MobileTab,
@@ -84,12 +87,10 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
assert_eq!(config.git_remotes().len(), 1); assert_eq!(config.git_remotes().len(), 1);
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
assert_eq!(remote.name().as_str(), "origin"); assert_eq!(remote.name().as_str(), "origin");
assert_eq!( assert_eq!(remote.url(), "https://git.example.test/alice/store.git");
remote.url().as_str(), let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
"https://git.example.test/alice/store.git" assert_eq!(server_id.as_str(), "personal-git");
); assert_eq!(application_id.as_str(), "ironstorage-cli");
assert_eq!(remote.server_id().as_str(), "personal-git");
assert_eq!(remote.application_id().as_str(), "ironstorage-cli");
assert_eq!( assert_eq!(
config.clipboard_timeout().duration(), config.clipboard_timeout().duration(),
DEFAULT_CLIPBOARD_TIMEOUT DEFAULT_CLIPBOARD_TIMEOUT
@@ -564,10 +565,9 @@ fn missing_and_invalid_required_fields_are_typed() -> TestResult {
} }
#[test] #[test]
fn git_configuration_rejects_non_https_and_embedded_credentials() -> TestResult { fn git_configuration_rejects_forbidden_transports_and_embedded_credentials() -> TestResult {
let fixture = ConfigurationFixture::new()?; let fixture = ConfigurationFixture::new()?;
for url in [ for url in [
"ssh://git@example.test/store.git",
"git://example.test/store.git", "git://example.test/store.git",
"file:///tmp/store.git", "file:///tmp/store.git",
"../store.git", "../store.git",
@@ -602,6 +602,159 @@ application_id = "application"
Ok(()) Ok(())
} }
#[test]
fn ssh_remote_endpoints_parse_to_one_typed_contract() -> TestResult {
let absolute = RemoteEndpoint::parse("ssh://git@example.test:2222/repos/store.git")?;
assert_eq!(absolute.transport(), RemoteTransport::Ssh);
let ssh = absolute.as_ssh().expect("SSH endpoint");
assert_eq!(ssh.user(), Some("git"));
assert_eq!(ssh.host(), "example.test");
assert_eq!(ssh.port(), 2222);
assert_eq!(
ssh.path(),
&SshRepositoryPath::Absolute("/repos/store.git".to_owned())
);
assert_eq!(
RemoteEndpoint::parse("ssh://git@example.test/repos/store.git")?,
RemoteEndpoint::parse("git@example.test:/repos/store.git")?
);
assert_eq!(
RemoteEndpoint::parse("ssh://git@example.test/~alice/store.git")?,
RemoteEndpoint::parse("git@example.test:~alice/store.git")?
);
let relative = RemoteEndpoint::parse("git@example.test:team/store.git")?;
assert_eq!(
relative.as_ssh().expect("SSH endpoint").path(),
&SshRepositoryPath::Relative("team/store.git".to_owned())
);
let ipv6 = RemoteEndpoint::parse("ssh://git@[2001:db8::1]:2200/store.git")?;
assert_eq!(ipv6.as_ssh().expect("IPv6 endpoint").port(), 2200);
let ipv4 = RemoteEndpoint::parse("git@192.0.2.10:team/store.git")?;
assert_eq!(ipv4.as_ssh().expect("IPv4 endpoint").host(), "192.0.2.10");
let unicode = RemoteEndpoint::parse("git@bücher.example:team/密码.git")?;
let unicode = unicode.as_ssh().expect("Unicode endpoint");
assert_eq!(unicode.host(), "xn--bcher-kva.example");
assert_eq!(unicode.path().as_str(), "team/密码.git");
let inert = "team/repo';touch${IFS}pwned.git";
assert_eq!(
RemoteEndpoint::parse(&format!("git@example.test:{inert}"))?
.as_ssh()
.expect("literal path")
.path()
.as_str(),
inert
);
Ok(())
}
#[test]
fn remote_credentials_are_explicitly_transport_specific() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
[[git.remotes]]
name = "origin"
url = "git@example.test:team/store.git"
server_id = "server"
application_id = "application"
"#,
)?;
assert_eq!(
fixture
.loader()
.load(Some(&fixture.explicit_path()))
.expect_err("HTTPS credentials cannot configure SSH"),
ConfigError::InvalidField {
field: "git.remotes.https_credentials"
}
);
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
[[git.remotes]]
name = "origin"
url = "https://example.test/team/store.git"
"#,
)?;
assert_eq!(
fixture
.loader()
.load(Some(&fixture.explicit_path()))
.expect_err("HTTPS credentials are required"),
ConfigError::MissingField {
field: "git.remotes.server_id"
}
);
Ok(())
}
#[test]
fn ssh_remote_configuration_round_trips_without_https_credentials() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
[[git.remotes]]
name = "origin"
url = "git@example.test:team/store.git"
"#,
)?;
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
let remote = &config.git_remotes()[0];
assert_eq!(remote.url(), "git@example.test:team/store.git");
assert!(remote.https_credentials().is_none());
config.update_git_identity(&GitIdentity::new("Alice", "alice@example.test")?)?;
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
assert_eq!(reloaded.git_remotes(), config.git_remotes());
let persisted = fs::read_to_string(fixture.explicit_path())?;
assert!(!persisted.contains("server_id"));
assert!(!persisted.contains("application_id"));
Ok(())
}
#[test]
fn ssh_remote_parser_rejects_ambiguous_local_and_executable_inputs() {
for remote in [
"",
"ssh://example.test",
"ssh://example.test/",
"ssh://user:secret@example.test/store.git",
"ssh://example.test/store.git?option=value",
"ssh://example.test/store.git#fragment",
"ssh://example.test/%0Acommand",
"git://example.test/store.git",
"file:///tmp/store.git",
"ext::helper command",
"../store.git",
"/tmp/store.git",
"C:/store.git",
"git@2001:db8::1:store.git",
"git@example.test:-upload-pack=evil",
"git@example.test:repo\ncommand",
] {
assert!(
RemoteEndpoint::parse(remote).is_err(),
"forbidden remote should fail closed: {remote:?}"
);
}
}
#[test] #[test]
fn duplicate_remote_names_and_credential_references_are_rejected() -> TestResult { fn duplicate_remote_names_and_credential_references_are_rejected() -> TestResult {
let fixture = ConfigurationFixture::new()?; let fixture = ConfigurationFixture::new()?;

View File

@@ -137,13 +137,11 @@ fn nested_repository_selection_is_innermost() -> TestResult {
} }
#[test] #[test]
fn remotes_and_config_are_local_https_only() -> TestResult { fn remotes_and_config_reject_local_helper_and_credential_urls() -> TestResult {
let temporary = tempfile::tempdir()?; let temporary = tempfile::tempdir()?;
let store = Repository::open(temporary.path())?; let store = Repository::open(temporary.path())?;
let mut git = GitRepository::init(&store, identity())?; let mut git = GitRepository::init(&store, identity())?;
for forbidden in [ for forbidden in [
"ssh://example.test/store.git",
"git@example.test:store.git",
"git://example.test/store.git", "git://example.test/store.git",
"file:///tmp/store.git", "file:///tmp/store.git",
"../store.git", "../store.git",
@@ -172,6 +170,52 @@ fn remotes_and_config_are_local_https_only() -> TestResult {
Ok(()) Ok(())
} }
#[cfg(not(feature = "ssh"))]
#[test]
fn ssh_remotes_are_typed_but_unavailable_before_transport_or_mutation() -> TestResult {
let temporary = tempfile::tempdir()?;
let store = Repository::open(temporary.path())?;
let mut git = GitRepository::init(&store, identity())?;
let remote = GitRemote::ssh("origin", "git@example.test:team/store.git")?;
let unsupported = GitError::UnsupportedRemoteTransport {
transport: ironstorage::config::RemoteTransport::Ssh,
};
assert_eq!(
git.add_remote(remote.name().as_str(), remote.url()),
Err(unsupported.clone())
);
assert!(git.remotes().is_empty());
assert_eq!(
git.config_set("remote.origin.url", remote.url()),
Err(unsupported.clone())
);
assert!(git.config_get("remote.origin.url")?.is_none());
assert_eq!(
GitRepository::discover_remote_branches_with_transport(
temporary.path(),
identity(),
&remote,
&Credentials,
&CloningFetch,
&GitOperationControl::default(),
),
Err(unsupported)
);
Ok(())
}
#[cfg(feature = "ssh")]
#[test]
fn ssh_feature_allows_repository_remote_configuration() -> TestResult {
let temporary = tempfile::tempdir()?;
let store = Repository::open(temporary.path())?;
let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", "git@example.test:team/store.git")?;
assert_eq!(git.remote_url("origin")?, "git@example.test:team/store.git");
Ok(())
}
struct Credentials; struct Credentials;
impl GitCredentialProvider for Credentials { impl GitCredentialProvider for Credentials {
@@ -347,7 +391,7 @@ fn injected_smart_http_push_sends_a_complete_pack_and_credentials() -> TestResul
let remote: &GitRemote = &config.git_remotes()[0]; let remote: &GitRemote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?; let store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?; let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url().as_str())?; git.add_remote("origin", remote.url())?;
fs::write(config.vault().join("secret.gpg"), b"ciphertext")?; fs::write(config.vault().join("secret.gpg"), b"ciphertext")?;
git.stage(&["secret.gpg".into()])?; git.stage(&["secret.gpg".into()])?;
let head = git.commit("Add secret to store.")?; let head = git.commit("Add secret to store.")?;
@@ -381,7 +425,7 @@ fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?; let store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?; let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url().as_str())?; git.add_remote("origin", remote.url())?;
let head = git.log(Some(1))?[0].id().to_owned(); let head = git.log(Some(1))?[0].id().to_owned();
set_remote_tracking(config.vault(), &head)?; set_remote_tracking(config.vault(), &head)?;
let phases = Arc::new(Mutex::new(Vec::new())); let phases = Arc::new(Mutex::new(Vec::new()));
@@ -409,7 +453,7 @@ fn push_propagates_authentication_and_rejects_non_fast_forward_before_upload() -
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?; let store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?; let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url().as_str())?; git.add_remote("origin", remote.url())?;
assert_eq!( assert_eq!(
git.push_with_transport(remote, Some("main"), &Credentials, &AuthenticationFailure), git.push_with_transport(remote, Some("main"), &Credentials, &AuthenticationFailure),
Err(GitError::AuthenticationFailed) Err(GitError::AuthenticationFailed)
@@ -433,7 +477,7 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?; let store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?; let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url().as_str())?; git.add_remote("origin", remote.url())?;
fs::write(config.vault().join("secret.gpg"), b"base")?; fs::write(config.vault().join("secret.gpg"), b"base")?;
git.stage(&["secret.gpg".into()])?; git.stage(&["secret.gpg".into()])?;
@@ -511,7 +555,7 @@ fn cancelled_operations_stop_before_credentials_or_transport() -> TestResult {
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?; let store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?; let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url().as_str())?; git.add_remote("origin", remote.url())?;
let control = GitOperationControl::default(); let control = GitOperationControl::default();
control.cancel(); control.cancel();
assert_eq!( assert_eq!(

View File

@@ -339,20 +339,21 @@ fn git_account_status_and_removal_never_expose_the_token() -> TestResult {
"personal-git", "personal-git",
"ironstorage-mobile", "ironstorage-mobile",
)?; )?;
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
store.unlock()?; store.unlock()?;
store.store_https_git_credential( store.store_https_git_credential(
remote.server_id(), server_id,
remote.application_id(), application_id,
"alice", "alice",
SecretBytes::new(b"private-token".to_vec()), SecretBytes::new(b"private-token".to_vec()),
)?; )?;
assert_eq!( assert_eq!(
store.https_git_credential_account(remote.server_id(), remote.application_id())?, store.https_git_credential_account(server_id, application_id)?,
"alice" "alice"
); );
store.delete_https_git_credential(remote.server_id(), remote.application_id())?; store.delete_https_git_credential(server_id, application_id)?;
assert!(matches!( assert!(matches!(
store.https_git_credential_account(remote.server_id(), remote.application_id()), store.https_git_credential_account(server_id, application_id),
Err(SecretStoreError::Missing) Err(SecretStoreError::Missing)
)); ));
assert!(!format!("{store:?}").contains("private-token")); assert!(!format!("{store:?}").contains("private-token"));
@@ -396,28 +397,29 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult
let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native")) let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native"))
.load(Some(&temporary.path().join("config.toml")))?; .load(Some(&temporary.path().join("config.toml")))?;
let remote = &config.git_remotes()[0]; let remote = &config.git_remotes()[0];
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
backend.fail_next(SecretStoreError::Cancelled); backend.fail_next(SecretStoreError::Cancelled);
assert!(matches!( assert!(matches!(
store.credential(remote.server_id(), remote.application_id()), store.credential(server_id, application_id),
Err(GitError::CredentialCancelled) Err(GitError::CredentialCancelled)
)); ));
backend.fail_next(SecretStoreError::Denied); backend.fail_next(SecretStoreError::Denied);
assert!(matches!( assert!(matches!(
store.credential(remote.server_id(), remote.application_id()), store.credential(server_id, application_id),
Err(GitError::CredentialAccessDenied) Err(GitError::CredentialAccessDenied)
)); ));
let git = SecretReference::https_git_credential( let git = SecretReference::https_git_credential(
remote.server_id().as_str(), server_id.as_str(),
remote.application_id().as_str(), application_id.as_str(),
"alice", "alice",
)?; )?;
store.create(&git, SecretBytes::new(b"https-token".to_vec()))?; store.create(&git, SecretBytes::new(b"https-token".to_vec()))?;
let credential = store.credential(remote.server_id(), remote.application_id())?; let credential = store.credential(server_id, application_id)?;
assert_eq!(credential.username(), "alice"); assert_eq!(credential.username(), "alice");
assert_eq!(credential.password(), b"https-token"); assert_eq!(credential.password(), b"https-token");
store.store_https_git_credential( store.store_https_git_credential(
remote.server_id(), server_id,
remote.application_id(), application_id,
"bob", "bob",
SecretBytes::new(b"replacement-token".to_vec()), SecretBytes::new(b"replacement-token".to_vec()),
)?; )?;
@@ -425,7 +427,7 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult
store.retrieve(&git), store.retrieve(&git),
Err(SecretStoreError::Missing) Err(SecretStoreError::Missing)
)); ));
let credential = store.credential(remote.server_id(), remote.application_id())?; let credential = store.credential(server_id, application_id)?;
assert_eq!(credential.username(), "bob"); assert_eq!(credential.username(), "bob");
assert_eq!(credential.password(), b"replacement-token"); assert_eq!(credential.password(), b"replacement-token");
assert!( assert!(

View File

@@ -62,13 +62,38 @@ configured editor.
IronStorage. Both must be present together and cannot contain line breaks or IronStorage. Both must be present together and cannot contain line breaks or
angle brackets. The built-in IronStorage identity is used when both are absent. angle brackets. The built-in IronStorage identity is used when both are absent.
Git remotes are HTTPS-only. URLs containing user information, passwords, HTTPS remote URLs containing user information, passwords, queries, or fragments
queries, or fragments are rejected. `server_id` and `application_id` are opaque are rejected. `server_id` and `application_id` are opaque
references used to retrieve credentials from the operating-system secret HTTPS-only references used to retrieve credentials from the operating-system
store; duplicate names and duplicate reference pairs are errors. secret store; duplicate names and duplicate reference pairs are errors.
The HTTPS account name is stored inside the protected credential record, not in The HTTPS account name is stored inside the protected credential record, not in
TOML. OpenPGP passphrases are addressed by the resolved primary fingerprint. TOML. OpenPGP passphrases are addressed by the resolved primary fingerprint.
SSH remotes use either `ssh://[user@]host[:port]/path` or scp-like
`[user@]host:path` syntax and omit the HTTPS credential fields:
```toml
[[git.remotes]]
name = "origin"
url = "git@git.example.test:alice/password-store.git"
```
The typed endpoint model is always available so an SSH remote remains readable
in configuration even when the binary was built without SSH. Such a build
returns a typed unsupported-transport error before connection or repository
mutation. The optional storage `ssh` feature contains `russh` 0.63.1 and Tokio;
`russh` default features are disabled and the Ring backend plus RSA key support
are selected explicitly.
For SSH, URI paths are absolute, scp-like paths without a leading slash are
relative to the remote account, and `~`/`~user` paths retain tilde-expansion
semantics. Bracketed IPv6 and explicit URI ports are accepted. Host names are
IDNA-normalized, repository paths may contain Unicode, and usernames are
restricted to ASCII letters, digits, `.`, `_`, and `-`. Empty paths, control
bytes, credentials, queries, fragments, ambiguous unbracketed IPv6 or colon
paths, leading-option paths, local paths, URL rewrites, separate push URLs,
helper transports, and unknown schemes fail closed.
`clipboard_timeout_seconds` controls the native clipboard presentation lease. `clipboard_timeout_seconds` controls the native clipboard presentation lease.
It defaults to 45 seconds for upstream `pass` compatibility and must be between It defaults to 45 seconds for upstream `pass` compatibility and must be between
1 and 300 seconds. The CLI remains alive for the lease so Linux can serve its 1 and 300 seconds. The CLI remains alive for the lease so Linux can serve its

View File

@@ -20,13 +20,22 @@ committer. Only their affected paths are staged, unrelated index state is
preserved, no-op mutations create no commit, and commit failures restore the preserved, no-op mutations create no commit, and commit failures restore the
index so the storage transaction can roll back its files. index so the storage transaction can roll back its files.
## Remote endpoint contract
Storage parses credential-free HTTPS URLs plus feature-gated `ssh://` and
scp-like SSH URLs into one typed endpoint contract. Local paths, `git://`,
`file://`, helper transports, URL rewrites, separate push URLs, embedded
credentials, and unknown schemes are rejected before transport. A build
without the `ssh` feature reports SSH as unsupported before connection or
repository mutation instead of treating its configuration as malformed.
## HTTPS transport ## HTTPS transport
Remote URLs must be absolute, credential-free HTTPS URLs. SSH, scp syntax, HTTPS credentials are requested with the configured server ID and application
`git://`, `file://`, local paths, helper transports, URL rewrites, separate push ID and remain outside Git configuration. The SSH session, host-verification,
URLs, and unknown schemes are rejected before transport. Credentials are authentication, and pack-protocol implementations are separate milestone work;
requested with the configured server ID and application ID and remain outside until those layers are present, network operations on SSH endpoints return the
Git configuration. typed unsupported-transport result.
Fetch uses the embedded Rust smart-HTTP client with an explicit credential Fetch uses the embedded Rust smart-HTTP client with an explicit credential
callback, so Git's credential cascade is never entered. Push implements the callback, so Git's credential cascade is never entered. Push implements the