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,7 +7,10 @@ use ironstorage::{
authentication::{
AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT,
},
config::{ConfigError, ConfigLoader, EditorSource, MobileAppearance},
config::{
ConfigError, ConfigLoader, EditorSource, MobileAppearance, RemoteEndpoint, RemoteTransport,
SshRepositoryPath,
},
desktop::DesktopStorage,
git::GitIdentity,
mobile::MobileTab,
@@ -84,12 +87,10 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
assert_eq!(config.git_remotes().len(), 1);
let remote = &config.git_remotes()[0];
assert_eq!(remote.name().as_str(), "origin");
assert_eq!(
remote.url().as_str(),
"https://git.example.test/alice/store.git"
);
assert_eq!(remote.server_id().as_str(), "personal-git");
assert_eq!(remote.application_id().as_str(), "ironstorage-cli");
assert_eq!(remote.url(), "https://git.example.test/alice/store.git");
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
assert_eq!(server_id.as_str(), "personal-git");
assert_eq!(application_id.as_str(), "ironstorage-cli");
assert_eq!(
config.clipboard_timeout().duration(),
DEFAULT_CLIPBOARD_TIMEOUT
@@ -564,10 +565,9 @@ fn missing_and_invalid_required_fields_are_typed() -> TestResult {
}
#[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()?;
for url in [
"ssh://git@example.test/store.git",
"git://example.test/store.git",
"file:///tmp/store.git",
"../store.git",
@@ -602,6 +602,159 @@ application_id = "application"
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]
fn duplicate_remote_names_and_credential_references_are_rejected() -> TestResult {
let fixture = ConfigurationFixture::new()?;

View File

@@ -137,13 +137,11 @@ fn nested_repository_selection_is_innermost() -> TestResult {
}
#[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 store = Repository::open(temporary.path())?;
let mut git = GitRepository::init(&store, identity())?;
for forbidden in [
"ssh://example.test/store.git",
"git@example.test:store.git",
"git://example.test/store.git",
"file:///tmp/store.git",
"../store.git",
@@ -172,6 +170,52 @@ fn remotes_and_config_are_local_https_only() -> TestResult {
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;
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 store = Repository::open(config.vault())?;
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")?;
git.stage(&["secret.gpg".into()])?;
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 store = Repository::open(config.vault())?;
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();
set_remote_tracking(config.vault(), &head)?;
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 store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url().as_str())?;
git.add_remote("origin", remote.url())?;
assert_eq!(
git.push_with_transport(remote, Some("main"), &Credentials, &AuthenticationFailure),
Err(GitError::AuthenticationFailed)
@@ -433,7 +477,7 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
let remote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?;
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")?;
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 store = Repository::open(config.vault())?;
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();
control.cancel();
assert_eq!(

View File

@@ -339,20 +339,21 @@ fn git_account_status_and_removal_never_expose_the_token() -> TestResult {
"personal-git",
"ironstorage-mobile",
)?;
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
store.unlock()?;
store.store_https_git_credential(
remote.server_id(),
remote.application_id(),
server_id,
application_id,
"alice",
SecretBytes::new(b"private-token".to_vec()),
)?;
assert_eq!(
store.https_git_credential_account(remote.server_id(), remote.application_id())?,
store.https_git_credential_account(server_id, application_id)?,
"alice"
);
store.delete_https_git_credential(remote.server_id(), remote.application_id())?;
store.delete_https_git_credential(server_id, application_id)?;
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)
));
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"))
.load(Some(&temporary.path().join("config.toml")))?;
let remote = &config.git_remotes()[0];
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
backend.fail_next(SecretStoreError::Cancelled);
assert!(matches!(
store.credential(remote.server_id(), remote.application_id()),
store.credential(server_id, application_id),
Err(GitError::CredentialCancelled)
));
backend.fail_next(SecretStoreError::Denied);
assert!(matches!(
store.credential(remote.server_id(), remote.application_id()),
store.credential(server_id, application_id),
Err(GitError::CredentialAccessDenied)
));
let git = SecretReference::https_git_credential(
remote.server_id().as_str(),
remote.application_id().as_str(),
server_id.as_str(),
application_id.as_str(),
"alice",
)?;
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.password(), b"https-token");
store.store_https_git_credential(
remote.server_id(),
remote.application_id(),
server_id,
application_id,
"bob",
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),
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.password(), b"replacement-token");
assert!(