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