Files
IronStorage/crates/storage/tests/git_embedded.rs
Chili Palmer a3da9fda69
Some checks failed
Dependency security audit / rustsec (push) Has been cancelled
Complete SSH transport release audit (#118)
2026-08-25 22:07:47 +02:00

919 lines
31 KiB
Rust

#![forbid(unsafe_code)]
mod support;
use std::{
error::Error,
fs,
io::Cursor,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use ironstorage::{
command::{InsertInput, InsertRequest},
config::{Config, ConfigLoader, GitRemote, SshFingerprint},
crypto::{DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
git::{
GitChangeKind, GitConflictChoice, GitConflictResolution, GitCredential,
GitCredentialProvider, GitError, GitFetchTransport, GitIdentity, GitOperationControl,
GitProgressPhase, GitRemoteCredentialProvider, GitRepository, GitSmartHttpTransport,
PullOutcome, SshPassphraseProvider,
},
repository::{Repository, SecretBytes},
write::{InsertContent, OverwriteDecision, VaultWriter},
};
use pgp::{
composed::{Deserializable as _, DetachedSignature},
ser::Serialize as _,
};
use sha1::Digest as _;
use support::compatibility::FixtureSet;
type TestResult = Result<(), Box<dyn Error>>;
fn identity() -> GitIdentity {
GitIdentity::new("IronStorage Test", "test@ironstorage.invalid").expect("valid identity")
}
fn remote_config(temporary: &tempfile::TempDir) -> Result<Config, Box<dyn Error>> {
fs::create_dir_all(temporary.path().join("config/keys"))?;
fs::create_dir_all(temporary.path().join("native"))?;
fs::create_dir_all(temporary.path().join("vault"))?;
let config_path = temporary.path().join("config/config.toml");
fs::write(
&config_path,
format!(
"vault = {:?}\ndefault_key = \"0123456789ABCDEF0123456789ABCDEF01234567\"\nkey_material = \"keys\"\n[[git.remotes]]\nname = \"origin\"\nurl = \"https://example.test/store.git\"\nserver_id = \"server\"\napplication_id = \"application\"\n",
temporary.path().join("vault")
),
)?;
Ok(
ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native"))
.load(Some(&config_path))?,
)
}
#[test]
fn local_git_workflow_stages_commits_diffs_logs_and_deletes() -> TestResult {
let temporary = tempfile::tempdir()?;
let store = Repository::open(temporary.path())?;
let git = GitRepository::init(&store, identity())?;
fs::write(temporary.path().join(".gpg-id"), b"ALICE\n")?;
git.stage(&[".gpg-id".into()])?;
let first = git.commit("Set password store recipients.")?;
assert_eq!(first.len(), 40);
assert!(git.status()?.is_clean());
fs::write(temporary.path().join(".gpg-id"), b"BOB\n")?;
let status = git.status()?;
assert_eq!(status.unstaged().len(), 1);
assert_eq!(status.unstaged()[0].kind(), GitChangeKind::Modified);
let diff = git.diff(&[])?;
assert_eq!(diff[0].old(), Some(b"ALICE\n".as_slice()));
assert_eq!(diff[0].current(), Some(b"BOB\n".as_slice()));
let fixture = FixtureSet::load()?;
let keys = KeyStore::load(fixture.path("keys"))?;
let rendered = git.render_diff(&[], &keys, &mut SigningSecret(Vec::new()))?;
assert!(rendered.expose().windows(7).any(|part| part == b"-ALICE\n"));
assert!(rendered.expose().windows(5).any(|part| part == b"+BOB\n"));
git.stage(&[".gpg-id".into()])?;
assert_eq!(git.status()?.staged()[0].kind(), GitChangeKind::Modified);
git.commit("Change password store recipients.")?;
assert_eq!(git.log(None)?.len(), 3);
assert_eq!(
git.log(Some(1))?[0].message(),
"Change password store recipients."
);
assert_eq!(git.stage_and_commit(&[".gpg-id".into()], "No-op")?, None);
fs::remove_file(temporary.path().join(".gpg-id"))?;
git.stage(&[".gpg-id".into()])?;
let deletion = git.status()?;
assert_eq!(deletion.staged().len(), 1, "{deletion:?}");
assert_eq!(deletion.staged()[0].kind(), GitChangeKind::Deleted);
git.commit("Remove password store recipients.")?;
assert!(git.status()?.is_clean());
Ok(())
}
#[test]
fn user_commit_stages_every_current_change_in_storage() -> TestResult {
let temporary = tempfile::tempdir()?;
let store = Repository::open(temporary.path())?;
let git = GitRepository::init(&store, identity())?;
fs::write(temporary.path().join(".gpg-id"), b"ALICE\n")?;
fs::write(temporary.path().join("mail.gpg"), b"ciphertext")?;
let commit = git.commit_all("Save mobile changes.")?;
assert_eq!(commit.len(), 40);
assert!(git.status()?.is_clean());
assert_eq!(git.log(Some(1))?[0].message(), "Save mobile changes.");
assert_eq!(git.commit_all("Nothing changed"), Err(GitError::NoChanges));
Ok(())
}
#[test]
fn nested_repository_selection_is_innermost() -> TestResult {
let temporary = tempfile::tempdir()?;
let outer_store = Repository::open(temporary.path())?;
GitRepository::init(&outer_store, identity())?;
let root = GitRepository::open_innermost(&outer_store, Path::new(""), identity())?;
assert_eq!(root.root(), fs::canonicalize(temporary.path())?);
fs::create_dir(temporary.path().join("nested"))?;
let inner_store = Repository::open(temporary.path().join("nested"))?;
GitRepository::init(&inner_store, identity())?;
fs::write(temporary.path().join("nested/secret.gpg"), b"ciphertext")?;
let selected =
GitRepository::open_innermost(&outer_store, Path::new("nested/secret.gpg"), identity())?;
assert_eq!(
selected.root(),
fs::canonicalize(temporary.path().join("nested"))?
);
Ok(())
}
#[test]
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 [
"git://example.test/store.git",
"file:///tmp/store.git",
"../store.git",
"ext::helper command",
"https://user:secret@example.test/store.git",
] {
assert_eq!(
git.add_remote("origin", forbidden),
Err(GitError::ForbiddenRemoteUrl)
);
}
git.add_remote("origin", "https://example.test/store.git")?;
assert_eq!(git.remotes(), ["origin"]);
assert_eq!(git.remote_url("origin")?, "https://example.test/store.git");
git.set_remote_url("origin", "https://example.test/other.git")?;
assert_eq!(git.remote_url("origin")?, "https://example.test/other.git");
git.config_set("user.name", "Local User")?;
assert_eq!(git.config_get("user.name")?.as_deref(), Some("Local User"));
assert!(git.config_set("credential.helper", "evil").is_err());
assert!(
git.config_set("diff.gpg.textconv", "external-helper")
.is_err()
);
git.remove_remote("origin")?;
assert!(git.remotes().is_empty());
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_both_remote_forms_through_add_set_and_get() -> 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");
git.set_remote_url("origin", "ssh://git@example.test/team/store.git")?;
assert_eq!(
git.remote_url("origin")?,
"ssh://git@example.test/team/store.git"
);
Ok(())
}
struct Credentials;
impl GitCredentialProvider for Credentials {
fn credential(
&self,
server: &ironstorage::config::ServerId,
application: &ironstorage::config::ApplicationId,
) -> Result<GitCredential, GitError> {
assert_eq!(server.as_str(), "server");
assert_eq!(application.as_str(), "application");
GitCredential::new("alice", b"token".to_vec())
}
}
impl SshPassphraseProvider for Credentials {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
Err(GitError::SshKeyPassphraseUnavailable {
fingerprint: fingerprint.clone(),
})
}
}
fn http_credential(
configured: &GitRemote,
credentials: &dyn GitRemoteCredentialProvider,
) -> Result<GitCredential, GitError> {
let (server, application) = configured
.https_credentials()
.ok_or(GitError::CredentialsUnavailable)?;
credentials.credential(server, application)
}
struct SigningSecret(Vec<u8>);
impl SecretProvider for SigningSecret {
fn secret_for(&mut self, _key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
Ok(SecretBytes::new(self.0.clone()))
}
}
#[derive(Default)]
struct RecordingTransport {
request: Mutex<Vec<u8>>,
}
struct CloningFetch;
impl GitFetchTransport for CloningFetch {
fn fetch(
&self,
repository: &GitRepository,
configured: &GitRemote,
credentials: &dyn GitRemoteCredentialProvider,
) -> Result<bool, GitError> {
let credential = http_credential(configured, credentials)?;
assert_eq!(credential.password(), b"token");
fs::write(repository.root().join(".gpg-id"), b"ALICE\n")
.map_err(|error| GitError::InvalidRepository(error.to_string()))?;
repository.stage(&[".gpg-id".into()])?;
let tip = repository.commit("Fetched initial store.")?;
set_remote_tracking(repository.root(), &tip)
.map_err(|error| GitError::InvalidRepository(error.to_string()))?;
reset_to_unborn(repository.root())
.map_err(|error| GitError::InvalidRepository(error.to_string()))?;
Ok(true)
}
}
struct OccupyingFetch(PathBuf);
impl GitFetchTransport for OccupyingFetch {
fn fetch(
&self,
repository: &GitRepository,
configured: &GitRemote,
credentials: &dyn GitRemoteCredentialProvider,
) -> Result<bool, GitError> {
let fetched = CloningFetch.fetch(repository, configured, credentials)?;
fs::write(self.0.join("keep"), b"concurrent contents")
.map_err(|error| GitError::InvalidRepository(error.to_string()))?;
Ok(fetched)
}
}
struct NoopFetch;
impl GitFetchTransport for NoopFetch {
fn fetch(
&self,
_repository: &GitRepository,
configured: &GitRemote,
credentials: &dyn GitRemoteCredentialProvider,
) -> Result<bool, GitError> {
let credential = http_credential(configured, credentials)?;
assert_eq!(credential.username(), "alice");
Ok(false)
}
}
struct FailedFetch(GitError);
impl GitFetchTransport for FailedFetch {
fn fetch(
&self,
_repository: &GitRepository,
_configured: &GitRemote,
_credentials: &dyn GitRemoteCredentialProvider,
) -> Result<bool, GitError> {
Err(self.0.clone())
}
}
struct AuthenticationFailure;
impl GitSmartHttpTransport for AuthenticationFailure {
fn advertise_receive_pack(
&self,
_url: &url::Url,
_credential: &GitCredential,
) -> Result<Vec<u8>, GitError> {
Err(GitError::AuthenticationFailed)
}
fn receive_pack(
&self,
_url: &url::Url,
_credential: &GitCredential,
_request: Vec<u8>,
) -> Result<Vec<u8>, GitError> {
unreachable!("authentication fails before receive-pack")
}
}
struct NonFastForwardAdvertisement;
impl GitSmartHttpTransport for NonFastForwardAdvertisement {
fn advertise_receive_pack(
&self,
_url: &url::Url,
_credential: &GitCredential,
) -> Result<Vec<u8>, GitError> {
let mut output = packet(b"# service=git-receive-pack\n");
output.extend_from_slice(b"0000");
output.extend_from_slice(&packet(
b"1111111111111111111111111111111111111111 refs/heads/main\0report-status\n",
));
output.extend_from_slice(b"0000");
Ok(output)
}
fn receive_pack(
&self,
_url: &url::Url,
_credential: &GitCredential,
_request: Vec<u8>,
) -> Result<Vec<u8>, GitError> {
unreachable!("non-fast-forward is rejected before receive-pack")
}
}
impl GitSmartHttpTransport for RecordingTransport {
fn advertise_receive_pack(
&self,
_url: &url::Url,
credential: &GitCredential,
) -> Result<Vec<u8>, GitError> {
assert_eq!(credential.username(), "alice");
let mut output = packet(b"# service=git-receive-pack\n");
output.extend_from_slice(b"0000");
output.extend_from_slice(&packet(b"0000000000000000000000000000000000000000 capabilities^{}\0report-status delete-refs\n"));
output.extend_from_slice(b"0000");
Ok(output)
}
fn receive_pack(
&self,
_url: &url::Url,
_credential: &GitCredential,
request: Vec<u8>,
) -> Result<Vec<u8>, GitError> {
*self.request.lock().expect("request lock") = request;
let mut output = packet(b"unpack ok\n");
output.extend_from_slice(&packet(b"ok refs/heads/main\n"));
output.extend_from_slice(b"0000");
Ok(output)
}
}
#[test]
fn injected_smart_http_push_sends_a_complete_pack_and_credentials() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
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())?;
fs::write(config.vault().join("secret.gpg"), b"ciphertext")?;
git.stage(&["secret.gpg".into()])?;
let head = git.commit("Add secret to store.")?;
let transport = RecordingTransport::default();
let outcome = git.push_with_transport(remote, Some("main"), &Credentials, &transport)?;
assert_eq!(outcome.new_id(), head);
let request = transport.request.lock().expect("request lock");
let pack_offset = request
.windows(4)
.position(|window| window == b"PACK")
.expect("pack payload");
assert!(
request[..pack_offset]
.windows(b"refs/heads/main".len())
.any(|window| window == b"refs/heads/main")
);
let pack = &request[pack_offset..];
assert_eq!(&pack[..4], b"PACK");
assert_eq!(u32::from_be_bytes(pack[4..8].try_into()?), 2);
assert!(u32::from_be_bytes(pack[8..12].try_into()?) >= 3);
let digest = sha1::Sha1::digest(&pack[..pack.len() - 20]);
assert_eq!(digest.as_slice(), &pack[pack.len() - 20..]);
Ok(())
}
#[test]
fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
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())?;
let head = git.log(Some(1))?[0].id().to_owned();
set_remote_tracking(config.vault(), &head)?;
let phases = Arc::new(Mutex::new(Vec::new()));
let recorded = Arc::clone(&phases);
let control = GitOperationControl::new(move |phase| {
recorded.lock().expect("progress").push(phase);
});
let push = RecordingTransport::default();
let (pull, pushed) =
git.sync_with_transports_controlled(remote, &Credentials, &NoopFetch, &push, &control)?;
assert_eq!(pull, PullOutcome::UpToDate);
assert_eq!(pushed.new_id(), head);
let phases = phases.lock().expect("progress");
assert!(phases.contains(&GitProgressPhase::Receiving));
assert!(phases.contains(&GitProgressPhase::Integrating));
assert!(phases.contains(&GitProgressPhase::Sending));
assert!(!push.request.lock().expect("push request").is_empty());
Ok(())
}
#[test]
fn push_propagates_authentication_and_rejects_non_fast_forward_before_upload() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
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())?;
assert_eq!(
git.push_with_transport(remote, Some("main"), &Credentials, &AuthenticationFailure),
Err(GitError::AuthenticationFailed)
);
assert_eq!(
git.push_with_transport(
remote,
Some("main"),
&Credentials,
&NonFastForwardAdvertisement,
),
Err(GitError::NonFastForward)
);
Ok(())
}
#[test]
fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
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())?;
fs::write(config.vault().join("secret.gpg"), b"base")?;
git.stage(&["secret.gpg".into()])?;
let base = git.commit("Base")?;
fs::write(config.vault().join("secret.gpg"), b"remote")?;
git.stage(&["secret.gpg".into()])?;
let remote_tip = git.commit("Remote change")?;
reset_head_and_index(&git, config.vault(), &base, b"base")?;
set_remote_tracking(config.vault(), &remote_tip)?;
assert_eq!(
git.pull_with_transport(remote, Some("main"), &Credentials, &NoopFetch)?,
PullOutcome::FastForward
);
assert_eq!(fs::read(config.vault().join("secret.gpg"))?, b"remote");
reset_head_and_index(&git, config.vault(), &base, b"base")?;
fs::write(config.vault().join("secret.gpg"), b"local")?;
git.stage(&["secret.gpg".into()])?;
git.commit("Local change")?;
let error = git
.pull_with_transport(remote, Some("main"), &Credentials, &NoopFetch)
.expect_err("conflicting histories");
let GitError::MergeConflicts { conflicts } = error else {
panic!("expected typed merge conflicts");
};
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].path(), Path::new("secret.gpg"));
assert_eq!(
conflicts[0].kind(),
ironstorage::git::GitConflictKind::Content
);
assert_eq!(fs::read(config.vault().join("secret.gpg"))?, b"local");
let snapshot = git.snapshot(Some(remote), 5)?;
assert_eq!(snapshot.remote().expect("remote status").ahead(), 1);
assert_eq!(snapshot.remote().expect("remote status").behind(), 1);
let divergence = git.divergence(remote, 10)?;
assert_eq!(divergence.remote().ahead(), 1);
assert_eq!(divergence.remote().behind(), 1);
assert_eq!(divergence.incoming().len(), 1);
assert_eq!(divergence.incoming()[0].commit().message(), "Remote change");
assert_eq!(divergence.incoming()[0].changes().len(), 1);
assert_eq!(
divergence.incoming()[0].changes()[0].path(),
Path::new("secret.gpg")
);
assert_eq!(divergence.outgoing().len(), 1);
assert_eq!(divergence.outgoing()[0].commit().message(), "Local change");
let totals_only = git.divergence(remote, 0)?;
assert_eq!(totals_only.remote().ahead(), 1);
assert_eq!(totals_only.remote().behind(), 1);
assert!(totals_only.incoming().is_empty());
assert!(totals_only.outgoing().is_empty());
assert_eq!(
git.resolve_fetched(
remote,
Some("main"),
&[GitConflictResolution::new(
"secret.gpg".into(),
GitConflictChoice::Remote,
)],
)?,
PullOutcome::Merged
);
assert_eq!(fs::read(config.vault().join("secret.gpg"))?, b"remote");
assert_eq!(git.log(Some(1))?[0].parents().len(), 2);
Ok(())
}
#[test]
fn cancelled_operations_stop_before_credentials_or_transport() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
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())?;
let control = GitOperationControl::default();
control.cancel();
assert_eq!(
git.pull_with_transport_controlled(
remote,
Some("main"),
&Credentials,
&NoopFetch,
&control,
),
Err(GitError::Cancelled)
);
assert!(git.status()?.is_clean());
let phases = Arc::new(Mutex::new(Vec::new()));
let recorded = Arc::clone(&phases);
let progress = GitOperationControl::new(move |phase| {
recorded.lock().expect("progress lock").push(phase);
});
assert!(matches!(
git.pull_with_transport_controlled(
remote,
Some("main"),
&Credentials,
&NoopFetch,
&progress,
),
Err(GitError::RemoteNotFound { .. })
));
assert_eq!(
*phases.lock().expect("progress lock"),
[
GitProgressPhase::Validating,
GitProgressPhase::Authenticating,
GitProgressPhase::Receiving,
GitProgressPhase::Receiving,
GitProgressPhase::Integrating,
]
);
for expected in [GitError::NetworkUnavailable, GitError::TlsFailed] {
assert_eq!(
git.pull_with_transport(
remote,
Some("main"),
&Credentials,
&FailedFetch(expected.clone()),
),
Err(expected)
);
}
let credential = GitCredential::new("alice", b"DO-NOT-RENDER".to_vec())?;
assert_eq!(format!("{credential:?}"), "GitCredential([REDACTED])");
Ok(())
}
#[test]
fn clone_uses_a_private_directory_and_injected_fetch_transport() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
let destination = temporary.path().join("cloned-vault");
let cloned = GitRepository::clone_into_with_transport(
&destination,
identity(),
&config.git_remotes()[0],
&Credentials,
&CloningFetch,
)?;
assert_eq!(cloned.root(), destination);
assert_eq!(fs::read(destination.join(".gpg-id"))?, b"ALICE\n");
assert_eq!(cloned.remotes(), ["origin"]);
assert_eq!(cloned.log(None)?.len(), 1);
assert_eq!(cloned.log(None)?[0].message(), "Fetched initial store.");
assert!(!destination.join(".gitattributes").exists());
assert!(
!temporary
.path()
.read_dir()?
.filter_map(Result::ok)
.any(|entry| entry
.file_name()
.to_string_lossy()
.starts_with(".ironstorage-clone-"))
);
Ok(())
}
#[test]
fn branch_discovery_and_controlled_clone_stay_inside_rust() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
let remote = &config.git_remotes()[0];
let control = GitOperationControl::default();
let branches = GitRepository::discover_remote_branches_with_transport(
temporary.path(),
identity(),
remote,
&Credentials,
&CloningFetch,
&control,
)?;
assert_eq!(branches, ["main"]);
assert!(
!temporary
.path()
.read_dir()?
.filter_map(Result::ok)
.any(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(".ironstorage-probe-")
})
);
let destination = temporary.path().join("selected-branch");
let cloned = GitRepository::clone_into_with_transport_controlled(
&destination,
identity(),
remote,
Some(&branches[0]),
&Credentials,
&CloningFetch,
&control,
)?;
assert_eq!(cloned.current_branch()?, "main");
assert_eq!(fs::read(destination.join(".gpg-id"))?, b"ALICE\n");
Ok(())
}
#[test]
fn cancelled_clone_never_touches_an_existing_destination() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
let destination = temporary.path().join("existing");
fs::create_dir(&destination)?;
fs::write(destination.join("keep"), b"unchanged")?;
let control = GitOperationControl::default();
control.cancel();
let result = GitRepository::clone_into_with_transport_controlled(
&destination,
identity(),
&config.git_remotes()[0],
Some("main"),
&Credentials,
&CloningFetch,
&control,
);
assert!(matches!(result, Err(GitError::Cancelled)));
assert_eq!(fs::read(destination.join("keep"))?, b"unchanged");
Ok(())
}
#[test]
fn clone_race_preserves_the_destination_and_removes_private_work() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
let destination = temporary.path().join("concurrent");
fs::create_dir(&destination)?;
let result = GitRepository::clone_into_with_transport(
&destination,
identity(),
&config.git_remotes()[0],
&Credentials,
&OccupyingFetch(destination.clone()),
);
assert!(matches!(result, Err(GitError::Io { .. })));
assert_eq!(fs::read(destination.join("keep"))?, b"concurrent contents");
assert!(
!temporary
.path()
.read_dir()?
.filter_map(Result::ok)
.any(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(".ironstorage-clone-")
})
);
Ok(())
}
#[test]
fn signed_commits_have_a_verifiable_ascii_armored_gpgsig() -> TestResult {
let fixture = FixtureSet::load()?;
let alice = fixture.key("alice")?;
let keys = KeyStore::load(fixture.path("keys"))?;
let signer = keys.resolve(&alice.primary_fingerprint)?;
let temporary = tempfile::tempdir()?;
let store = Repository::open(temporary.path())?;
let git = GitRepository::init(&store, identity())?;
fs::write(temporary.path().join("secret.gpg"), b"ciphertext")?;
git.stage(&["secret.gpg".into()])?;
git.commit_signed(
"Add signed password.",
&keys,
&signer,
&mut SigningSecret(alice.passphrase.as_bytes().to_vec()),
)?;
let repository = open_test_git(temporary.path())?;
let head = repository.head_commit()?;
let decoded = head.decode()?;
let mut commit = decoded.into_owned()?;
let signature_index = commit
.extra_headers
.iter()
.position(|(name, _)| name.as_slice() == b"gpgsig")
.expect("gpgsig header");
let armor = commit.extra_headers.remove(signature_index).1;
assert!(armor.starts_with(b"-----BEGIN PGP SIGNATURE-----"));
let (signature, _) = DetachedSignature::from_armor_single(Cursor::new(armor))?;
let mut signature_bytes = Vec::new();
signature.to_writer(&mut signature_bytes)?;
let mut unsigned = Vec::new();
gix::objs::WriteTo::write_to(&commit, &mut unsigned)?;
assert_eq!(
keys.verify(
&unsigned,
&DetachedSignatureBytes::new(signature_bytes),
std::slice::from_ref(&signer),
)?,
signer
);
Ok(())
}
#[test]
fn successful_storage_mutations_use_the_embedded_committer() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut git = GitRepository::init(&repository, identity())?;
assert_eq!(
fs::read(store.path().join(".gitattributes"))?,
b"*.gpg diff=gpg\n"
);
let initialized = git.log(None)?;
assert_eq!(initialized.len(), 2);
assert_eq!(
initialized[0].message(),
"Configure git repository for gpg file diff."
);
assert_eq!(
initialized[1].message(),
"Add current contents of password store."
);
fs::write(store.path().join("unrelated.gpg"), b"separately staged")?;
git.stage(&["unrelated.gpg".into()])?;
let writer = VaultWriter::new(&repository, &keys);
writer.insert(
&InsertRequest {
entry: "automatic/entry".to_owned(),
input: InsertInput::EchoedLine,
force: false,
},
InsertContent::echoed(b"generated secret".to_vec())?,
OverwriteDecision::Decline,
None,
&mut git,
)?;
let log = git.log(None)?;
assert_eq!(log.len(), 3);
assert_eq!(
log[0].message(),
"Add given password for automatic/entry to store."
);
let embedded = open_test_git(store.path())?;
let head = embedded.head_commit()?;
let committed = head.tree()?;
assert!(
committed
.lookup_entry_by_path("automatic/entry.gpg")?
.is_some()
);
assert!(committed.lookup_entry_by_path("unrelated.gpg")?.is_none());
let status = git.status()?;
assert_eq!(status.staged().len(), 1, "{status:?}");
assert_eq!(status.staged()[0].path(), Path::new("unrelated.gpg"));
assert_eq!(status.staged()[0].kind(), GitChangeKind::Added);
Ok(())
}
fn reset_head_and_index(git: &GitRepository, root: &Path, id: &str, contents: &[u8]) -> TestResult {
let repository = open_test_git(root)?;
let id = gix::hash::ObjectId::from_hex(id.as_bytes())?;
repository
.head_ref()?
.expect("born HEAD")
.set_target_id(id, "test reset")?;
fs::write(root.join("secret.gpg"), contents)?;
git.stage(&["secret.gpg".into()])?;
Ok(())
}
fn set_remote_tracking(root: &Path, id: &str) -> TestResult {
let repository = open_test_git(root)?;
let id = gix::hash::ObjectId::from_hex(id.as_bytes())?;
if let Some(existing) = repository.try_find_reference("refs/remotes/origin/main")? {
existing.delete()?;
}
repository.reference(
"refs/remotes/origin/main",
id,
gix::refs::transaction::PreviousValue::MustNotExist,
"test remote update",
)?;
Ok(())
}
fn reset_to_unborn(root: &Path) -> TestResult {
let repository = open_test_git(root)?;
repository.find_reference("refs/heads/main")?.delete()?;
let mut index = repository.index_from_tree(&repository.empty_tree().id)?;
index.write(Default::default())?;
fs::remove_file(root.join(".gpg-id"))?;
Ok(())
}
fn open_test_git(root: &Path) -> Result<gix::Repository, Box<dyn Error>> {
Ok(gix::open_opts(
root,
gix::open::Options::isolated().config_overrides([
"user.name=Test".to_owned(),
"user.email=test@ironstorage.invalid".to_owned(),
]),
)?)
}
fn packet(data: &[u8]) -> Vec<u8> {
let mut output = format!("{:04x}", data.len() + 4).into_bytes();
output.extend_from_slice(data);
output
}