Add typed SSH remote endpoints
This commit is contained in:
@@ -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()?;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user