673 lines
23 KiB
Rust
673 lines
23 KiB
Rust
#![forbid(unsafe_code)]
|
|
|
|
mod support;
|
|
|
|
use std::{
|
|
error::Error,
|
|
fs,
|
|
io::Cursor,
|
|
path::Path,
|
|
sync::{Arc, Mutex},
|
|
};
|
|
|
|
use ironstorage::{
|
|
command::{InsertInput, InsertRequest},
|
|
config::{Config, ConfigLoader, GitRemote},
|
|
crypto::{DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
|
git::{
|
|
GitChangeKind, GitConflictChoice, GitConflictResolution, GitCredential,
|
|
GitCredentialProvider, GitError, GitFetchTransport, GitIdentity, GitOperationControl,
|
|
GitProgressPhase, GitRepository, GitSmartHttpTransport, PullOutcome,
|
|
},
|
|
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 nested_repository_selection_is_innermost() -> TestResult {
|
|
let temporary = tempfile::tempdir()?;
|
|
let outer_store = Repository::open(temporary.path())?;
|
|
GitRepository::init(&outer_store, identity())?;
|
|
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_are_local_https_only() -> 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",
|
|
"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(())
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|
|
|
|
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,
|
|
credential: &GitCredential,
|
|
) -> Result<bool, GitError> {
|
|
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 NoopFetch;
|
|
|
|
impl GitFetchTransport for NoopFetch {
|
|
fn fetch(
|
|
&self,
|
|
_repository: &GitRepository,
|
|
_configured: &GitRemote,
|
|
credential: &GitCredential,
|
|
) -> Result<bool, GitError> {
|
|
assert_eq!(credential.username(), "alice");
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
struct FailedFetch(GitError);
|
|
|
|
impl GitFetchTransport for FailedFetch {
|
|
fn fetch(
|
|
&self,
|
|
_repository: &GitRepository,
|
|
_configured: &GitRemote,
|
|
_credential: &GitCredential,
|
|
) -> 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().as_str())?;
|
|
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 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().as_str())?;
|
|
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().as_str())?;
|
|
|
|
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);
|
|
|
|
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().as_str())?;
|
|
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 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
|
|
}
|