use std::{
collections::{BTreeSet, HashMap, HashSet},
path::Path,
};
use crate::{
crypto::{VaultKeyMetadata, integrity::bytes_sha256_b64},
domain::{error::DotLockError, model::DotLockResult},
storage::{
pending_merge::{PendingMergeMarker, load_marker, save_marker},
project,
secrets_lock::{DEFAULT_SECRET_ALG, SecretRecord, SecretsFile, load_secrets_file},
secure_fs,
vault_file::load_vault_metadata,
vault_txn::recover_pending,
},
};
pub fn run_merge_driver(
ours: &Path,
theirs: &Path,
base: &Path,
merged_path: Option<&Path>,
) -> DotLockResult<()> {
let env = env_from_merged_path(merged_path)?;
let lock_dir = project::env_lock_dir_for(env.as_deref());
let lock_dir = Path::new(&lock_dir);
recover_pending(
Path::new(&project::vault_file_for(env.as_deref())),
Path::new(&project::secrets_file_for(env.as_deref())),
)?;
match merge_target(merged_path.unwrap_or(ours)) {
MergeTarget::Secrets => merge_secrets_lock(ours, theirs, base, lock_dir),
MergeTarget::Vault => merge_vault_metadata(ours, theirs, base, lock_dir),
}
}
fn env_from_merged_path(merged_path: Option<&Path>) -> DotLockResult<Option<String>> {
let Some(path) = merged_path else {
return Ok(None);
};
let mut components = path
.components()
.filter_map(|component| component.as_os_str().to_str());
if components.next() != Some(project::DOTLOCK_DIR) || components.next() != Some("envs") {
return Ok(None);
}
let Some(env) = components.next() else {
return Ok(None);
};
project::validate_env_name(env)?;
Ok(Some(env.to_string()))
}
enum MergeTarget {
Secrets,
Vault,
}
fn merge_target(path: &Path) -> MergeTarget {
if path.file_name().and_then(|name| name.to_str()) == Some("vault.toml") {
MergeTarget::Vault
} else {
MergeTarget::Secrets
}
}
fn merge_secrets_lock(
ours: &Path,
theirs: &Path,
base: &Path,
lock_dir: &Path,
) -> DotLockResult<()> {
let ours_file = load_secrets_file(ours)?;
let theirs_file = load_secrets_file(theirs)?;
let base_file = load_secrets_file(base).unwrap_or_default();
let (merged, report) = merge_secrets_with_report(ours_file, theirs_file, base_file)?;
let content =
toml::to_string_pretty(&merged).map_err(|err| DotLockError::Crypto(err.to_string()))?;
secure_fs::write_string_atomic(ours, &content, 0o700, 0o600)?;
let mut marker = PendingMergeMarker::new();
marker.secrets_sha256_b64 = Some(bytes_sha256_b64(content.as_bytes()));
marker.merged_ids = merged.secrets.iter().map(|s| s.id.clone()).collect();
marker.theirs_won = report.theirs_won;
marker.added = report.added;
marker.changed = report.changed;
marker.removed = report.removed;
save_marker(lock_dir, &marker)
}
#[derive(Debug, Default)]
struct SecretsMergeReport {
theirs_won: Vec<String>,
added: Vec<String>,
changed: Vec<String>,
removed: Vec<String>,
}
fn merge_secrets_with_report(
ours: SecretsFile,
theirs: SecretsFile,
base: SecretsFile,
) -> DotLockResult<(SecretsFile, SecretsMergeReport)> {
let version = ours.version.max(theirs.version);
let ours_by_name: HashMap<String, SecretRecord> = ours
.secrets
.into_iter()
.map(|secret| (secret.name.clone(), secret))
.collect();
let theirs_by_name: HashMap<String, SecretRecord> = theirs
.secrets
.into_iter()
.map(|secret| (secret.name.clone(), secret))
.collect();
let base_by_name: HashMap<String, SecretRecord> = base
.secrets
.into_iter()
.map(|secret| (secret.name.clone(), secret))
.collect();
let mut names = BTreeSet::new();
names.extend(ours_by_name.keys().cloned());
names.extend(theirs_by_name.keys().cloned());
names.extend(base_by_name.keys().cloned());
let mut secrets = Vec::new();
let mut report = SecretsMergeReport::default();
for name in names {
let ours_record = ours_by_name.get(&name);
let chosen = choose_secret(
&name,
ours_record,
theirs_by_name.get(&name),
base_by_name.get(&name),
)?;
match (ours_record, chosen) {
(Some(_), None) => report.removed.push(name.clone()),
(None, Some(_)) => report.added.push(name.clone()),
(Some(ours_record), Some(chosen)) => {
if !same_secret_revision(ours_record, chosen) {
report.changed.push(name.clone());
if ours_record.id == chosen.id {
report.theirs_won.push(chosen.id.clone());
}
}
}
(None, None) => {}
}
if let Some(secret) = chosen {
secrets.push(secret.clone());
}
}
Ok((SecretsFile { version, secrets }, report))
}
fn choose_secret<'a>(
name: &str,
ours: Option<&'a SecretRecord>,
theirs: Option<&'a SecretRecord>,
base: Option<&SecretRecord>,
) -> DotLockResult<Option<&'a SecretRecord>> {
match (ours, theirs, base) {
(Some(ours), Some(theirs), _) => choose_latest(name, ours, theirs).map(Some),
(Some(ours), None, Some(base)) => {
if same_secret_revision(ours, base) {
Ok(None)
} else {
Ok(Some(ours))
}
}
(None, Some(theirs), Some(base)) => {
if same_secret_revision(theirs, base) {
Ok(None)
} else {
Ok(Some(theirs))
}
}
(Some(ours), None, None) => Ok(Some(ours)),
(None, Some(theirs), None) => Ok(Some(theirs)),
(None, None, _) => Ok(None),
}
}
fn choose_latest<'a>(
name: &str,
ours: &'a SecretRecord,
theirs: &'a SecretRecord,
) -> DotLockResult<&'a SecretRecord> {
match ours.version.cmp(&theirs.version) {
std::cmp::Ordering::Greater => return Ok(ours),
std::cmp::Ordering::Less => return Ok(theirs),
std::cmp::Ordering::Equal => {}
}
if ours.updated_at > theirs.updated_at {
return Ok(ours);
}
if theirs.updated_at > ours.updated_at {
return Ok(theirs);
}
if ours.data != theirs.data {
return Err(DotLockError::Io(format!(
"manual merge required for secret `{name}`; both sides changed it at the same version and timestamp"
)));
}
Ok(ours)
}
fn same_secret_revision(left: &SecretRecord, right: &SecretRecord) -> bool {
left.id == right.id
&& left.name == right.name
&& effective_alg(left) == effective_alg(right)
&& left.data == right.data
&& left.updated_at == right.updated_at
&& left.version == right.version
}
fn effective_alg(secret: &SecretRecord) -> &str {
secret.alg.as_deref().unwrap_or(DEFAULT_SECRET_ALG)
}
fn merge_vault_metadata(
ours: &Path,
theirs: &Path,
base: &Path,
lock_dir: &Path,
) -> DotLockResult<()> {
let ours_original = secure_fs::read_to_string(ours)?;
let ours_metadata = load_vault_metadata(ours)?;
let theirs_metadata = load_vault_metadata(theirs)?;
let base_metadata = load_vault_metadata(base).ok();
let marker = load_marker(lock_dir)?;
let hash_fields_diverge =
ours_metadata.secrets_hash_sha256_b64 != theirs_metadata.secrets_hash_sha256_b64;
let (mut merged, vault_report) =
merge_metadata(ours_metadata, theirs_metadata.clone(), base_metadata)?;
let theirs_won: HashSet<&String> = marker
.iter()
.flat_map(|marker| marker.theirs_won.iter())
.collect();
union_sdk_wrappings(&mut merged, &theirs_metadata, &theirs_won);
if let Some(marker) = &marker {
for id in &marker.merged_ids {
if !merged.wrapped_sdks_under_dek.contains_key(id) {
return Err(DotLockError::MissingSecretKeyWrapping { id: id.clone() });
}
}
}
let mut content =
toml::to_string_pretty(&merged).map_err(|err| DotLockError::Crypto(err.to_string()))?;
if content != ours_original {
merged.metadata_mac_b64 = String::new();
content =
toml::to_string_pretty(&merged).map_err(|err| DotLockError::Crypto(err.to_string()))?;
}
secure_fs::write_string_atomic(ours, &content, 0o700, 0o600)?;
let has_rejections =
!vault_report.rejected_recipients.is_empty() || !vault_report.rejected_signers.is_empty();
if marker.is_some() || hash_fields_diverge || has_rejections {
let mut marker = marker.unwrap_or_default();
marker.vault_sha256_b64 = Some(bytes_sha256_b64(content.as_bytes()));
marker.rejected_recipients = vault_report.rejected_recipients;
marker.rejected_signers = vault_report.rejected_signers;
save_marker(lock_dir, &marker)?;
}
Ok(())
}
fn union_sdk_wrappings(
merged: &mut VaultKeyMetadata,
theirs: &VaultKeyMetadata,
theirs_won: &HashSet<&String>,
) {
for (id, wrapped) in &theirs.wrapped_sdks_under_dek {
let take_theirs =
theirs_won.contains(id) || !merged.wrapped_sdks_under_dek.contains_key(id);
if take_theirs {
merged
.wrapped_sdks_under_dek
.insert(id.clone(), wrapped.clone());
}
}
for theirs_recipient in &theirs.recipients {
let Some(recipient) = merged.recipients.iter_mut().find(|recipient| {
recipient.public_key_fingerprint == theirs_recipient.public_key_fingerprint
}) else {
continue;
};
for (id, wrapped) in &theirs_recipient.wrapped_sdks {
let take_theirs = theirs_won.contains(id) || !recipient.wrapped_sdks.contains_key(id);
if take_theirs {
recipient.wrapped_sdks.insert(id.clone(), wrapped.clone());
}
}
}
}
#[derive(Debug, Default)]
pub struct VaultMergeReport {
pub rejected_recipients: Vec<String>,
pub rejected_signers: Vec<String>,
}
pub fn merge_metadata(
mut ours: VaultKeyMetadata,
theirs: VaultKeyMetadata,
base: Option<VaultKeyMetadata>,
) -> DotLockResult<(VaultKeyMetadata, VaultMergeReport)> {
if ours.project_uuid != theirs.project_uuid {
return Err(DotLockError::Io(
"manual merge required for vault.toml; project_uuid differs".to_string(),
));
}
if ours.kek_version != theirs.kek_version {
return Err(DotLockError::Io(
"manual merge required for vault.toml; kek_version differs".to_string(),
));
}
ours.version = ours.version.max(theirs.version).max(2);
ours.vault_epoch = ours.vault_epoch.max(theirs.vault_epoch);
let mut report = VaultMergeReport::default();
merge_authorized_signers(&mut ours, &theirs, base.as_ref(), &mut report);
merge_recipients(&mut ours, theirs, base, &mut report);
Ok((ours, report))
}
fn merge_authorized_signers(
ours: &mut VaultKeyMetadata,
theirs: &VaultKeyMetadata,
base: Option<&VaultKeyMetadata>,
report: &mut VaultMergeReport,
) {
let local_predates_signers = ours.authorized_signers.is_empty()
&& base.is_none_or(|base| base.authorized_signers.is_empty());
if local_predates_signers {
ours.authorized_signers = theirs.authorized_signers.clone();
return;
}
for signer in &theirs.authorized_signers {
let known = ours
.authorized_signers
.iter()
.any(|existing| existing.fingerprint == signer.fingerprint);
let was_in_base = base.is_some_and(|base| {
base.authorized_signers
.iter()
.any(|existing| existing.fingerprint == signer.fingerprint)
});
if !known && !was_in_base {
report
.rejected_signers
.push(format!("{} ({})", signer.label, signer.fingerprint));
}
}
}
fn merge_recipients(
ours: &mut VaultKeyMetadata,
theirs: VaultKeyMetadata,
base: Option<VaultKeyMetadata>,
report: &mut VaultMergeReport,
) {
let base_fingerprints: Vec<String> = base
.map(|metadata| {
metadata
.recipients
.into_iter()
.map(|recipient| recipient.public_key_fingerprint)
.collect()
})
.unwrap_or_default();
for recipient in theirs.recipients {
let exists = ours
.recipients
.iter()
.any(|existing| existing.public_key_fingerprint == recipient.public_key_fingerprint);
if exists {
continue;
}
let was_in_base = base_fingerprints
.iter()
.any(|fingerprint| fingerprint == &recipient.public_key_fingerprint);
if was_in_base {
continue;
}
if crate::storage::shared_access::recipient_grant_is_valid(
&ours.project_uuid,
&ours.authorized_signers,
&recipient,
) {
ours.recipients.push(recipient);
} else {
report.rejected_recipients.push(format!(
"{} ({})",
recipient.label, recipient.public_key_fingerprint
));
}
}
ours.recipients
.sort_by(|a, b| a.public_key_fingerprint.cmp(&b.public_key_fingerprint));
}
#[cfg(test)]
mod tests {
use crate::{
domain::model::DotLockResult,
storage::secrets_lock::{SecretKind, SecretRecord, SecretsFile},
};
fn merge_secrets(
ours: SecretsFile,
theirs: SecretsFile,
base: SecretsFile,
) -> DotLockResult<SecretsFile> {
super::merge_secrets_with_report(ours, theirs, base).map(|(merged, _)| merged)
}
fn secret(name: &str, data: &str, updated_at: i64) -> SecretRecord {
SecretRecord {
id: name.to_string(),
name: name.to_string(),
alg: None,
data: data.to_string(),
updated_at,
version: 0,
kind: SecretKind::Static,
}
}
fn versioned_secret(name: &str, data: &str, updated_at: i64, version: u64) -> SecretRecord {
SecretRecord {
version,
..secret(name, data, updated_at)
}
}
fn legacy_secret(name: &str, data: &str, updated_at: i64) -> SecretRecord {
SecretRecord {
alg: Some("xchacha20-poly1305".to_string()),
..secret(name, data, updated_at)
}
}
fn file(secrets: Vec<SecretRecord>) -> SecretsFile {
SecretsFile {
version: 1,
secrets,
}
}
#[test]
fn merges_different_secret_changes() {
let merged = merge_secrets(
file(vec![secret("A", "ours", 10)]),
file(vec![secret("B", "theirs", 11)]),
file(Vec::new()),
)
.expect("merge");
assert_eq!(merged.secrets.len(), 2);
assert_eq!(merged.secrets[0].name, "A");
assert_eq!(merged.secrets[1].name, "B");
}
#[test]
fn latest_timestamp_wins_for_same_secret() {
let merged = merge_secrets(
file(vec![secret("A", "old", 10)]),
file(vec![secret("A", "new", 20)]),
file(Vec::new()),
)
.expect("merge");
assert_eq!(merged.secrets[0].data, "new");
}
#[test]
fn higher_version_wins_even_with_older_timestamp() {
let merged = merge_secrets(
file(vec![versioned_secret("A", "v3", 10, 3)]),
file(vec![versioned_secret("A", "v2", 999, 2)]),
file(Vec::new()),
)
.expect("merge");
assert_eq!(merged.secrets[0].data, "v3");
}
#[test]
fn same_timestamp_conflict_requires_manual_merge() {
let result = merge_secrets(
file(vec![secret("A", "ours", 10)]),
file(vec![secret("A", "theirs", 10)]),
file(Vec::new()),
);
assert!(result.is_err());
}
#[test]
fn deletion_wins_when_other_side_did_not_change_secret() {
let base = secret("A", "base", 10);
let merged = merge_secrets(file(Vec::new()), file(vec![base.clone()]), file(vec![base]))
.expect("merge");
assert!(merged.secrets.is_empty());
}
#[test]
fn missing_default_alg_matches_legacy_default_alg() {
let base = secret("A", "base", 10);
let theirs = legacy_secret("A", "base", 10);
let merged =
merge_secrets(file(Vec::new()), file(vec![theirs]), file(vec![base])).expect("merge");
assert!(merged.secrets.is_empty());
}
#[test]
fn update_wins_when_other_side_deleted_older_base_secret() {
let merged = merge_secrets(
file(Vec::new()),
file(vec![secret("A", "updated", 20)]),
file(vec![secret("A", "base", 10)]),
)
.expect("merge");
assert_eq!(merged.secrets[0].data, "updated");
}
}
#[cfg(test)]
mod driver_tests {
use std::{
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use crate::{
crypto::{
AccessMode, AuthorizedSigner, VaultConfig, VaultKeyMetadata, VaultRecipient,
integrity::verify_secrets_integrity,
sdk,
share::{
IdentityProtection, encode_public_key_b64, generate_identity, sign_recipient_grant,
},
},
domain::{error::DotLockError, model::Alg},
storage::{
pending_merge::{ensure_no_pending_merge, load_marker, reconcile_pending_merge},
secrets_lock::{decrypt_record_with_key, load_secrets_file, upsert_plain_secret},
secure_fs,
shared_access::recipient_grant_payload,
vault_file::{load_vault_metadata, save_vault_metadata},
},
};
fn dek() -> crate::domain::keys::ProjectKey {
crate::domain::keys::ProjectKey::new([8u8; 32])
}
fn temp_dir(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let dir = std::env::temp_dir().join(format!("dotlock-merge-{name}-{unique}"));
fs::create_dir_all(&dir).expect("create dir");
dir
}
fn metadata() -> VaultKeyMetadata {
VaultKeyMetadata {
version: 2,
project_uuid: "project".to_string(),
project: "dotlock".to_string(),
environment: "dev".to_string(),
kdf: "argon2id".to_string(),
salt_b64: "salt".to_string(),
memory_kib: 1,
iterations: 1,
parallelism: 1,
kek_version: 1,
kek_writes_since_rotate: 0,
wrapped_dek_nonce_b64: "nonce".to_string(),
wrapped_dek_b64: "wrapped".to_string(),
wrapped_sdks_under_dek: std::collections::HashMap::new(),
access_mode: AccessMode::MasterPassword,
recipients: Vec::new(),
authorized_signers: Vec::new(),
config: VaultConfig::default(),
secrets_hash_nonce_b64: "hash_nonce".to_string(),
secrets_hash_b64: "hash".to_string(),
secrets_hash_sha256_b64: "hash_plain".to_string(),
last_rotated_at: 0,
vault_epoch: 0,
metadata_mac_b64: String::new(),
}
}
struct Vault {
dir: PathBuf,
vault: PathBuf,
secrets: PathBuf,
}
impl Vault {
fn init(name: &str) -> Self {
let dir = temp_dir(name);
let vault = dir.join("vault.toml");
let secrets = dir.join("secrets.lock");
save_vault_metadata(&vault, &metadata()).expect("save vault");
Self {
dir,
vault,
secrets,
}
}
fn copy_to(&self, name: &str) -> Self {
let dir = temp_dir(name);
let vault = dir.join("vault.toml");
let secrets = dir.join("secrets.lock");
fs::copy(&self.vault, &vault).expect("copy vault");
fs::copy(&self.secrets, &secrets).expect("copy secrets");
Self {
dir,
vault,
secrets,
}
}
fn set(&self, name: &str, value: &str) {
upsert_plain_secret(
&self.secrets,
name.to_string(),
value.to_string(),
Alg::XChaCha20Poly1305,
&dek(),
self.vault.to_str().expect("vault path"),
&mut crate::storage::vault_file::load_vault_metadata(&self.vault)
.expect("load vault metadata"),
)
.expect("upsert");
}
fn forge_record_metadata(&self, name: &str, updated_at: i64, version: u64) {
let mut file = load_secrets_file(&self.secrets).expect("load secrets");
let secret = file
.secrets
.iter_mut()
.find(|secret| secret.name == name)
.expect("secret present");
secret.updated_at = updated_at;
secret.version = version;
let content = toml::to_string_pretty(&file).expect("serialize");
secure_fs::write_string_atomic(&self.secrets, &content, 0o700, 0o600)
.expect("write secrets");
}
fn cleanup(self) {
let _ = fs::remove_dir_all(self.dir);
}
}
fn run_driver(
ours: &Vault,
theirs: &Vault,
base: &Vault,
) -> crate::domain::model::DotLockResult<()> {
super::merge_secrets_lock(&ours.secrets, &theirs.secrets, &base.secrets, &ours.dir)?;
super::merge_vault_metadata(&ours.vault, &theirs.vault, &base.vault, &ours.dir)
}
fn decrypt_with_wrapping(merged: &Vault, name: &str) -> String {
let file = load_secrets_file(&merged.secrets).expect("load secrets");
let metadata = load_vault_metadata(&merged.vault).expect("load vault");
let secret = file
.secrets
.iter()
.find(|secret| secret.name == name)
.unwrap_or_else(|| panic!("secret {name} missing from merge"));
let wrapped = metadata
.wrapped_sdks_under_dek
.get(&secret.id)
.unwrap_or_else(|| panic!("secret {name} lost its SDK wrapping in the merge"));
let sdk = sdk::unwrap_sdk_with_project_key(wrapped, &dek()).expect("unwrap sdk");
decrypt_record_with_key(secret, &sdk)
.unwrap_or_else(|err| panic!("secret {name} undecryptable after merge: {err}"))
.to_string()
}
#[test]
fn merge_unions_sdk_wrappings_for_secrets_added_on_both_sides() {
let base = Vault::init("k2-base");
base.set("A", "a-val");
let ours = base.copy_to("k2-ours");
ours.set("B", "b-val");
let theirs = base.copy_to("k2-theirs");
theirs.set("C", "c-val");
run_driver(&ours, &theirs, &base).expect("merge");
let file = load_secrets_file(&ours.secrets).expect("load merged secrets");
let metadata = load_vault_metadata(&ours.vault).expect("load merged vault");
assert_eq!(file.secrets.len(), 3);
for secret in &file.secrets {
assert!(
metadata.wrapped_sdks_under_dek.contains_key(&secret.id),
"secret {} has no wrapping after merge",
secret.name
);
}
assert_eq!(decrypt_with_wrapping(&ours, "A"), "a-val");
assert_eq!(decrypt_with_wrapping(&ours, "B"), "b-val");
assert_eq!(decrypt_with_wrapping(&ours, "C"), "c-val");
let marker = load_marker(&ours.dir).expect("marker").expect("present");
assert_eq!(marker.merged_ids.len(), 3);
assert!(marker.secrets_sha256_b64.is_some());
assert!(marker.vault_sha256_b64.is_some());
assert_eq!(marker.added, vec!["C".to_string()]);
assert!(matches!(
ensure_no_pending_merge(&ours.dir),
Err(DotLockError::UnreconciledMerge)
));
base.cleanup();
theirs.cleanup();
ours.cleanup();
}
#[test]
fn same_id_conflict_keeps_sdk_wrapping_from_winning_side() {
let base = Vault::init("k2-win-base");
base.set("A", "base-val"); let ours = base.copy_to("k2-win-ours");
ours.set("A", "ours-val"); let theirs = base.copy_to("k2-win-theirs");
let mut theirs_metadata = load_vault_metadata(&theirs.vault).expect("load theirs vault");
theirs_metadata.wrapped_sdks_under_dek.clear();
save_vault_metadata(&theirs.vault, &theirs_metadata).expect("save theirs vault");
theirs.set("A", "ignored"); theirs.set("A", "theirs-val");
run_driver(&ours, &theirs, &base).expect("merge");
assert_eq!(decrypt_with_wrapping(&ours, "A"), "theirs-val");
reconcile_pending_merge(&ours.vault, &ours.secrets, &ours.dir, &dek()).expect("reconcile");
assert_eq!(decrypt_with_wrapping(&ours, "A"), "theirs-val");
base.cleanup();
theirs.cleanup();
ours.cleanup();
}
#[test]
fn replayed_record_with_forged_metadata_is_rejected_at_reconcile() {
let base = Vault::init("h2-replay-base");
base.set("A", "old-value"); let ours = base.copy_to("h2-replay-ours");
ours.set("A", "new-value"); let theirs = base.copy_to("h2-replay-theirs");
theirs.forge_record_metadata("A", i64::MAX - 1, 99);
run_driver(&ours, &theirs, &base).expect("textual merge succeeds without keys");
let merged = load_secrets_file(&ours.secrets).expect("load merged");
assert_eq!(merged.secrets[0].version, 99);
let err = reconcile_pending_merge(&ours.vault, &ours.secrets, &ours.dir, &dek())
.expect_err("reconcile must reject the replayed record");
assert!(
err.to_string().contains("failed authentication"),
"unexpected error: {err}"
);
assert!(load_marker(&ours.dir).expect("load marker").is_some());
assert!(matches!(
ensure_no_pending_merge(&ours.dir),
Err(DotLockError::UnreconciledMerge)
));
base.cleanup();
theirs.cleanup();
ours.cleanup();
}
#[test]
fn merge_fails_when_a_merged_secret_would_lose_its_wrapping() {
let base = Vault::init("k2-orphan-base");
base.set("A", "a-val");
let ours = base.copy_to("k2-orphan-ours");
let theirs = base.copy_to("k2-orphan-theirs");
theirs.set("C", "c-val");
let mut theirs_metadata = load_vault_metadata(&theirs.vault).expect("load theirs vault");
let c_id = load_secrets_file(&theirs.secrets)
.expect("load theirs secrets")
.secrets
.iter()
.find(|secret| secret.name == "C")
.expect("C present")
.id
.clone();
theirs_metadata.wrapped_sdks_under_dek.remove(&c_id);
save_vault_metadata(&theirs.vault, &theirs_metadata).expect("save theirs vault");
let result = run_driver(&ours, &theirs, &base);
assert!(matches!(
result,
Err(DotLockError::MissingSecretKeyWrapping { ref id }) if id == &c_id
));
base.cleanup();
theirs.cleanup();
ours.cleanup();
}
#[test]
fn reconcile_re_signs_merged_vault_and_removes_marker() {
let base = Vault::init("k6-base");
base.set("A", "a-val");
let ours = base.copy_to("k6-ours");
ours.set("B", "b-val");
let theirs = base.copy_to("k6-theirs");
theirs.set("C", "c-val");
run_driver(&ours, &theirs, &base).expect("merge");
let metadata = load_vault_metadata(&ours.vault).expect("load merged vault");
assert!(verify_secrets_integrity(&ours.secrets, &metadata, &dek()).is_err());
reconcile_pending_merge(&ours.vault, &ours.secrets, &ours.dir, &dek()).expect("reconcile");
assert!(load_marker(&ours.dir).expect("load marker").is_none());
ensure_no_pending_merge(&ours.dir).expect("marker removed");
let metadata = load_vault_metadata(&ours.vault).expect("reload merged vault");
verify_secrets_integrity(&ours.secrets, &metadata, &dek())
.expect("integrity green after reconcile");
assert_eq!(decrypt_with_wrapping(&ours, "A"), "a-val");
assert_eq!(decrypt_with_wrapping(&ours, "B"), "b-val");
assert_eq!(decrypt_with_wrapping(&ours, "C"), "c-val");
base.cleanup();
theirs.cleanup();
ours.cleanup();
}
#[test]
fn reconcile_refuses_content_tampered_after_the_merge() {
let base = Vault::init("k6-tamper-base");
base.set("A", "a-val");
let ours = base.copy_to("k6-tamper-ours");
ours.set("B", "b-val");
let theirs = base.copy_to("k6-tamper-theirs");
theirs.set("C", "c-val");
run_driver(&ours, &theirs, &base).expect("merge");
let mut content = fs::read_to_string(&ours.secrets).expect("read merged secrets");
content.push_str("\n# tampered\n");
secure_fs::write_string_atomic(&ours.secrets, &content, 0o700, 0o600).expect("tamper");
let err = reconcile_pending_merge(&ours.vault, &ours.secrets, &ours.dir, &dek())
.expect_err("must refuse tampered content");
assert!(err.to_string().contains("no longer matches"));
assert!(load_marker(&ours.dir).expect("load marker").is_some());
base.cleanup();
theirs.cleanup();
ours.cleanup();
}
#[test]
fn unlock_is_blocked_with_reconcile_error_while_marker_exists() {
let base = Vault::init("k6-block-base");
base.set("A", "a-val");
let ours = base.copy_to("k6-block-ours");
ours.set("B", "b-val");
let theirs = base.copy_to("k6-block-theirs");
theirs.set("C", "c-val");
run_driver(&ours, &theirs, &base).expect("merge");
let result =
crate::storage::unlock_file::unlock_vault(ours.vault.to_str().expect("vault path"));
assert!(matches!(result, Err(DotLockError::UnreconciledMerge)));
base.cleanup();
theirs.cleanup();
ours.cleanup();
}
fn signer_for(identity: &crate::crypto::share::GeneratedIdentity) -> AuthorizedSigner {
AuthorizedSigner {
fingerprint: identity.fingerprint.clone(),
public_key_b64: encode_public_key_b64(&identity.public_key_pem).expect("pub b64"),
label: "owner".to_string(),
}
}
fn test_recipient(
label: &str,
fingerprint: &str,
public_key_b64: &str,
grant_signature_b64: &str,
grant_signer_fingerprint: &str,
) -> VaultRecipient {
VaultRecipient {
id: format!("{label}-id"),
label: label.to_string(),
alg: "rsa-oaep-sha256".to_string(),
public_key_fingerprint: fingerprint.to_string(),
public_key_b64: public_key_b64.to_string(),
wrapped_dek_b64: "wrapped".to_string(),
wrapped_sdks: std::collections::HashMap::new(),
full_access: true,
grant_signature_b64: grant_signature_b64.to_string(),
grant_signer_fingerprint: grant_signer_fingerprint.to_string(),
}
}
#[test]
fn merge_rejects_recipient_without_valid_grant_signature() {
let owner = generate_identity(IdentityProtection::Plain).expect("identity");
let dir = temp_dir("h3-reject");
let mut base_metadata = metadata();
base_metadata.authorized_signers = vec![signer_for(&owner)];
let base_path = dir.join("base-vault.toml");
let ours_path = dir.join("vault.toml");
let theirs_path = dir.join("theirs-vault.toml");
save_vault_metadata(&base_path, &base_metadata).expect("save base");
save_vault_metadata(&ours_path, &base_metadata).expect("save ours");
let mut theirs_metadata = base_metadata.clone();
theirs_metadata.recipients.push(test_recipient(
"mallory",
"mallory-fp",
"bWFsbG9yeQ==",
"", "",
));
save_vault_metadata(&theirs_path, &theirs_metadata).expect("save theirs");
super::merge_vault_metadata(&ours_path, &theirs_path, &base_path, &dir).expect("merge");
let merged = load_vault_metadata(&ours_path).expect("load merged");
assert!(
merged.recipients.is_empty(),
"injected recipient must not be absorbed"
);
let marker = load_marker(&dir).expect("marker").expect("present");
assert_eq!(marker.rejected_recipients, vec!["mallory (mallory-fp)"]);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn merge_rejects_recipient_with_grant_signed_by_unknown_key() {
let owner = generate_identity(IdentityProtection::Plain).expect("identity");
let mallory = generate_identity(IdentityProtection::Plain).expect("identity");
let dir = temp_dir("h3-selfsign");
let mut base_metadata = metadata();
base_metadata.authorized_signers = vec![signer_for(&owner)];
let base_path = dir.join("base-vault.toml");
let ours_path = dir.join("vault.toml");
let theirs_path = dir.join("theirs-vault.toml");
save_vault_metadata(&base_path, &base_metadata).expect("save base");
save_vault_metadata(&ours_path, &base_metadata).expect("save ours");
let mallory_pub_b64 = encode_public_key_b64(&mallory.public_key_pem).expect("pub b64");
let payload = recipient_grant_payload(
&base_metadata.project_uuid,
&mallory.fingerprint,
&mallory_pub_b64,
&mallory.fingerprint,
);
let signature =
sign_recipient_grant(&payload, &mallory.private_key_pem).expect("self-sign");
let mut theirs_metadata = base_metadata.clone();
theirs_metadata.authorized_signers.push(AuthorizedSigner {
fingerprint: mallory.fingerprint.clone(),
public_key_b64: mallory_pub_b64.clone(),
label: "mallory".to_string(),
});
theirs_metadata.recipients.push(test_recipient(
"mallory",
&mallory.fingerprint,
&mallory_pub_b64,
&signature,
&mallory.fingerprint,
));
save_vault_metadata(&theirs_path, &theirs_metadata).expect("save theirs");
super::merge_vault_metadata(&ours_path, &theirs_path, &base_path, &dir).expect("merge");
let merged = load_vault_metadata(&ours_path).expect("load merged");
assert!(merged.recipients.is_empty(), "self-signed grant absorbed");
assert_eq!(
merged.authorized_signers,
vec![signer_for(&owner)],
"injected signer absorbed"
);
let marker = load_marker(&dir).expect("marker").expect("present");
assert_eq!(marker.rejected_recipients.len(), 1);
assert_eq!(marker.rejected_signers.len(), 1);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn merge_accepts_recipient_with_valid_grant_signature() {
let owner = generate_identity(IdentityProtection::Plain).expect("identity");
let dir = temp_dir("h3-accept");
let mut base_metadata = metadata();
base_metadata.authorized_signers = vec![signer_for(&owner)];
let base_path = dir.join("base-vault.toml");
let ours_path = dir.join("vault.toml");
let theirs_path = dir.join("theirs-vault.toml");
save_vault_metadata(&base_path, &base_metadata).expect("save base");
save_vault_metadata(&ours_path, &base_metadata).expect("save ours");
let payload = recipient_grant_payload(
&base_metadata.project_uuid,
"carol-fp",
"Y2Fyb2w=",
&owner.fingerprint,
);
let signature = sign_recipient_grant(&payload, &owner.private_key_pem).expect("sign");
let mut theirs_metadata = base_metadata.clone();
theirs_metadata.recipients.push(test_recipient(
"carol",
"carol-fp",
"Y2Fyb2w=",
&signature,
&owner.fingerprint,
));
save_vault_metadata(&theirs_path, &theirs_metadata).expect("save theirs");
super::merge_vault_metadata(&ours_path, &theirs_path, &base_path, &dir).expect("merge");
let merged = load_vault_metadata(&ours_path).expect("load merged");
assert_eq!(merged.recipients.len(), 1);
assert_eq!(merged.recipients[0].label, "carol");
assert!(load_marker(&dir).expect("marker").is_none());
let _ = fs::remove_dir_all(dir);
}
#[test]
fn merged_path_resolves_the_environment_safely() {
let env = |p: &str| super::env_from_merged_path(Some(Path::new(p)));
assert_eq!(super::env_from_merged_path(None).expect("none"), None);
assert_eq!(env(".lock/vault.toml").expect("default"), None);
assert_eq!(env(".lock/secrets.lock").expect("default"), None);
assert_eq!(
env(".lock/envs/staging/vault.toml").expect("staging"),
Some("staging".to_string())
);
assert_eq!(
env(".lock/envs/prod-eu/secrets.lock").expect("prod-eu"),
Some("prod-eu".to_string())
);
assert_eq!(env("other/path.txt").expect("outside"), None);
assert!(matches!(
env(".lock/envs/.hidden/vault.toml"),
Err(DotLockError::InvalidEnvironmentName { .. })
));
}
#[test]
fn driver_paths_are_relative_to_the_lock_dir() {
let dir = temp_dir("marker-path");
assert_eq!(
crate::storage::pending_merge::marker_path(Path::new(".lock")),
Path::new(".lock").join("pending-merge")
);
let _ = fs::remove_dir_all(dir);
}
}