use std::collections::BTreeMap;
use std::path::Path;
use zeroize::Zeroizing;
use crate::crypto::artifact::{
ARTIFACT_VERSION, ArtifactError, ArtifactPayload, EncryptedEnvelope, SyncArtifact,
seal_envelope, unseal_envelope,
};
use crate::db::{ProjectId, Vault};
#[derive(Debug, thiserror::Error)]
pub enum SyncError {
#[error(transparent)]
Artifact(#[from] ArtifactError),
#[error("envy.enc not found at {0}")]
FileNotFound(String),
#[error("failed to read/write envy.enc: {0}")]
Io(String),
#[error("envy.enc has unsupported schema version {0}")]
UnsupportedVersion(u32),
#[error("no environments could be decrypted \u{2014} check your passphrase")]
NothingImported,
#[error("vault error: {0}")]
VaultError(String),
}
pub struct UnsealResult {
pub imported: BTreeMap<String, BTreeMap<String, Zeroizing<String>>>,
pub skipped: Vec<String>,
}
pub fn seal_artifact(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
passphrase: &str,
envs: Option<&[&str]>,
) -> Result<SyncArtifact, SyncError> {
if passphrase.trim().is_empty() {
return Err(SyncError::Artifact(ArtifactError::WeakPassphrase));
}
let env_names: Vec<String> = match envs {
Some(list) => list.iter().map(|s| s.to_lowercase()).collect(),
None => vault
.list_environments(project_id)
.map_err(|e| SyncError::VaultError(e.to_string()))?
.into_iter()
.map(|e| e.name)
.collect(),
};
let mut environments = BTreeMap::new();
for env_name in &env_names {
let secrets_map = crate::core::get_env_secrets(vault, master_key, project_id, env_name)
.map_err(|e| SyncError::VaultError(e.to_string()))?;
let secrets: BTreeMap<String, Zeroizing<String>> = secrets_map.into_iter().collect();
let payload = ArtifactPayload { secrets };
let envelope = seal_envelope(passphrase, &payload)?;
environments.insert(env_name.clone(), envelope);
}
Ok(SyncArtifact {
version: ARTIFACT_VERSION,
environments,
})
}
pub fn unseal_artifact(
artifact: &SyncArtifact,
passphrase: &str,
) -> Result<UnsealResult, SyncError> {
if passphrase.trim().is_empty() {
return Err(SyncError::Artifact(ArtifactError::WeakPassphrase));
}
if artifact.version != ARTIFACT_VERSION {
return Err(SyncError::UnsupportedVersion(artifact.version));
}
let mut imported: BTreeMap<String, BTreeMap<String, Zeroizing<String>>> = BTreeMap::new();
let mut skipped: Vec<String> = Vec::new();
for (env_name, envelope) in &artifact.environments {
match unseal_envelope(passphrase, env_name, envelope) {
Ok(payload) => {
imported.insert(env_name.clone(), payload.secrets);
}
Err(_) => {
skipped.push(env_name.clone());
}
}
}
Ok(UnsealResult { imported, skipped })
}
pub fn write_artifact(artifact: &SyncArtifact, path: &Path) -> Result<(), SyncError> {
write_artifact_atomic(artifact, path)
}
pub fn write_artifact_atomic(artifact: &SyncArtifact, path: &Path) -> Result<(), SyncError> {
let tmp = path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("envy.enc.tmp");
let json = serde_json::to_string_pretty(artifact).map_err(|e| SyncError::Io(e.to_string()))?;
std::fs::write(&tmp, json.as_bytes()).map_err(|e| SyncError::Io(e.to_string()))?;
std::fs::rename(&tmp, path).map_err(|e| SyncError::Io(format!("atomic rename failed: {e}")))?;
Ok(())
}
pub fn seal_env(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env_name: &str,
passphrase: &str,
) -> Result<EncryptedEnvelope, SyncError> {
let envelope = seal_env_unmarked(vault, master_key, project_id, env_name, passphrase)?;
mark_env_sealed(vault, project_id, env_name)?;
Ok(envelope)
}
pub fn seal_env_unmarked(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env_name: &str,
passphrase: &str,
) -> Result<EncryptedEnvelope, SyncError> {
if passphrase.trim().is_empty() {
return Err(SyncError::Artifact(ArtifactError::WeakPassphrase));
}
let secrets_map = crate::core::get_env_secrets(vault, master_key, project_id, env_name)
.map_err(|e| SyncError::VaultError(e.to_string()))?;
let secrets: BTreeMap<String, Zeroizing<String>> = secrets_map.into_iter().collect();
let payload = ArtifactPayload { secrets };
Ok(seal_envelope(passphrase, &payload)?)
}
pub fn mark_env_sealed(
vault: &Vault,
project_id: &ProjectId,
env_name: &str,
) -> Result<(), SyncError> {
let name = super::ops::normalize_env(env_name);
let env = vault
.get_environment_by_name(project_id, &name)
.map_err(|e| SyncError::VaultError(e.to_string()))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
vault
.upsert_sync_marker(&env.id, now)
.map_err(|e| SyncError::VaultError(e.to_string()))?;
Ok(())
}
pub fn rotate_env(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
artifact: &mut SyncArtifact,
env_name: &str,
current_passphrase: &str,
new_passphrase: &str,
) -> Result<(), SyncError> {
if new_passphrase.trim().is_empty() {
return Err(SyncError::Artifact(ArtifactError::WeakPassphrase));
}
let env_key = super::ops::normalize_env(env_name);
let existing_envelope = match artifact.environments.get(&env_key) {
Some(e) => e,
None => {
return Err(SyncError::Artifact(ArtifactError::MalformedArtifact(
format!("environment '{env_name}' not found in artifact"),
)));
}
};
if !check_envelope_passphrase(current_passphrase, env_name, existing_envelope) {
return Err(SyncError::Artifact(ArtifactError::MalformedEnvelope(
env_name.to_string(),
"current passphrase does not match the existing envelope".to_string(),
)));
}
let new_envelope = seal_env(vault, master_key, project_id, env_name, new_passphrase)?;
artifact.environments.insert(env_key, new_envelope);
Ok(())
}
pub fn check_envelope_passphrase(
passphrase: &str,
env_name: &str,
envelope: &EncryptedEnvelope,
) -> bool {
unseal_envelope(passphrase, env_name, envelope).is_ok()
}
pub fn unseal_env(
artifact: &SyncArtifact,
env_name: &str,
passphrase: &str,
) -> Result<Option<BTreeMap<String, Zeroizing<String>>>, SyncError> {
let envelope = match artifact.environments.get(env_name) {
Some(e) => e,
None => return Ok(None),
};
match unseal_envelope(passphrase, env_name, envelope) {
Ok(payload) => Ok(Some(payload.secrets)),
Err(_) => Ok(None), }
}
pub fn new_empty_artifact() -> SyncArtifact {
SyncArtifact {
version: ARTIFACT_VERSION,
environments: BTreeMap::new(),
}
}
pub fn read_artifact(path: &Path) -> Result<SyncArtifact, SyncError> {
if !path.exists() {
return Err(SyncError::FileNotFound(path.display().to_string()));
}
let content = std::fs::read_to_string(path).map_err(|e| SyncError::Io(e.to_string()))?;
let artifact: SyncArtifact = serde_json::from_str(&content)
.map_err(|e| SyncError::Artifact(ArtifactError::MalformedArtifact(e.to_string())))?;
if artifact.version != ARTIFACT_VERSION {
return Err(SyncError::UnsupportedVersion(artifact.version));
}
Ok(artifact)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::artifact::{ArtifactPayload, EncryptedEnvelope, seal_envelope};
use crate::db::{ProjectId, Vault};
use std::collections::BTreeMap;
use zeroize::Zeroizing;
const TEST_KEY: [u8; 32] = [7u8; 32];
fn open_test_vault(tmp: &tempfile::TempDir) -> (Vault, ProjectId) {
let path = tmp.path().join("vault.db");
let vault = Vault::open(&path, &TEST_KEY).expect("vault must open");
let pid = vault
.create_project("test-project")
.expect("project must be created");
(vault, pid)
}
fn make_envelope(passphrase: &str, key: &str, value: &str) -> EncryptedEnvelope {
let mut secrets = BTreeMap::new();
secrets.insert(key.to_string(), Zeroizing::new(value.to_string()));
let payload = ArtifactPayload { secrets };
seal_envelope(passphrase, &payload).expect("seal_envelope must succeed")
}
#[test]
fn seal_artifact_produces_valid_json_structure() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(
&vault,
&TEST_KEY,
&pid,
"development",
"STRIPE_KEY",
"sk_test",
)
.expect("set_secret must succeed");
let artifact = seal_artifact(&vault, &TEST_KEY, &pid, "team-pass", Some(&["development"]))
.expect("seal_artifact must succeed");
assert_eq!(artifact.version, ARTIFACT_VERSION);
assert!(
artifact.environments.contains_key("development"),
"must contain development"
);
let env = &artifact.environments["development"];
assert!(!env.ciphertext.is_empty(), "ciphertext must be non-empty");
assert!(!env.nonce.is_empty(), "nonce must be non-empty");
assert_eq!(env.kdf.algorithm, "argon2id");
}
#[test]
fn unseal_artifact_progressive_disclosure() {
let dev_envelope = make_envelope("dev-pass", "API_KEY", "dev-value");
let prod_envelope = make_envelope("prod-pass", "DB_URL", "prod-db");
let mut environments = BTreeMap::new();
environments.insert("development".to_string(), dev_envelope);
environments.insert("production".to_string(), prod_envelope);
let artifact = SyncArtifact {
version: ARTIFACT_VERSION,
environments,
};
let result = unseal_artifact(&artifact, "dev-pass").expect("unseal must succeed");
assert!(
result.imported.contains_key("development"),
"development must be imported"
);
assert!(
result.skipped.contains(&"production".to_string()),
"production must be skipped"
);
assert_eq!(result.imported.len(), 1);
assert_eq!(result.skipped.len(), 1);
}
#[test]
fn write_read_artifact_round_trip() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let path = tmp.path().join("envy.enc");
let dev_envelope = make_envelope("pass", "KEY", "val");
let mut environments = BTreeMap::new();
environments.insert("development".to_string(), dev_envelope);
let artifact = SyncArtifact {
version: ARTIFACT_VERSION,
environments,
};
write_artifact(&artifact, &path).expect("write must succeed");
let recovered = read_artifact(&path).expect("read must succeed");
assert_eq!(recovered.version, ARTIFACT_VERSION);
assert!(recovered.environments.contains_key("development"));
}
#[test]
fn read_artifact_malformed_json_returns_error() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let path = tmp.path().join("envy.enc");
std::fs::write(&path, b"not json at all").expect("write must succeed");
let result = read_artifact(&path);
assert!(
matches!(
result,
Err(SyncError::Artifact(ArtifactError::MalformedArtifact(_)))
),
"malformed JSON must return MalformedArtifact, got: {:?}",
result.err()
);
}
#[test]
fn read_artifact_unknown_version_returns_error() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let path = tmp.path().join("envy.enc");
std::fs::write(&path, b"{\"version\":999,\"environments\":{}}")
.expect("write must succeed");
let result = read_artifact(&path);
assert!(
matches!(result, Err(SyncError::UnsupportedVersion(999))),
"unknown version must return UnsupportedVersion(999), got: {:?}",
result.err()
);
}
#[test]
fn write_artifact_atomic_writes_correctly_and_removes_tmp() {
let tmp_dir = tempfile::tempdir().expect("tempdir must succeed");
let path = tmp_dir.path().join("envy.enc");
let tmp_path = tmp_dir.path().join("envy.enc.tmp");
let dev_envelope = make_envelope("pass", "KEY", "val");
let mut environments = BTreeMap::new();
environments.insert("development".to_string(), dev_envelope);
let artifact = SyncArtifact {
version: ARTIFACT_VERSION,
environments,
};
write_artifact_atomic(&artifact, &path).expect("atomic write must succeed");
let recovered = read_artifact(&path).expect("read must succeed after atomic write");
assert!(recovered.environments.contains_key("development"));
assert!(
!tmp_path.exists(),
"envy.enc.tmp must be removed after successful atomic write"
);
}
#[test]
fn check_envelope_passphrase_correct_and_wrong() {
let correct_envelope = make_envelope("pass-A", "SECRET", "value");
assert!(
check_envelope_passphrase("pass-A", "development", &correct_envelope),
"correct passphrase must return true"
);
assert!(
!check_envelope_passphrase("pass-B", "development", &correct_envelope),
"wrong passphrase must return false"
);
}
#[test]
fn seal_env_writes_sync_marker() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_KEY, &pid, "development", "API_KEY", "secret")
.expect("set_secret must succeed");
seal_env(&vault, &TEST_KEY, &pid, "development", "test-passphrase")
.expect("seal_env must succeed");
mark_env_sealed(&vault, &pid, "development").expect("marker must succeed");
let statuses = vault.environment_status(&pid).expect("environment_status");
let dev = statuses
.iter()
.find(|s| s.name == "development")
.expect("development must be present");
assert!(
dev.sealed_at.is_some(),
"sealed_at must be Some after seal_env"
);
assert!(
dev.sealed_at.unwrap() > 0,
"sealed_at must be a positive Unix timestamp"
);
}
#[test]
fn mark_env_sealed_accepts_mixed_case_name() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_KEY, &pid, "Development", "API_KEY", "secret")
.expect("set_secret must succeed");
mark_env_sealed(&vault, &pid, "Development").expect("marker must normalize the name");
let statuses = vault.environment_status(&pid).expect("environment_status");
let dev = statuses
.iter()
.find(|s| s.name == "development")
.expect("development row must exist");
assert!(dev.sealed_at.is_some(), "marker must be committed");
}
#[test]
fn rotate_env_happy_path_replaces_envelope() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_KEY, &pid, "development", "API_KEY", "secret")
.expect("set_secret must succeed");
let mut artifact = seal_artifact(&vault, &TEST_KEY, &pid, "pass-A", Some(&["development"]))
.expect("seal_artifact must succeed");
let dev_before = artifact.environments["development"].clone();
let dev_ciphertext_before = dev_before.ciphertext.clone();
let dev_nonce_before = dev_before.nonce.clone();
rotate_env(
&vault,
&TEST_KEY,
&pid,
&mut artifact,
"development",
"pass-A",
"pass-B",
)
.expect("rotate_env must succeed with correct current passphrase");
let dev_after = &artifact.environments["development"];
assert_ne!(
dev_after.ciphertext, dev_ciphertext_before,
"ciphertext must change after rotation"
);
assert_ne!(
dev_after.nonce, dev_nonce_before,
"nonce must be regenerated after rotation"
);
assert!(
check_envelope_passphrase("pass-B", "development", dev_after),
"new passphrase must decrypt the new envelope"
);
assert!(
!check_envelope_passphrase("pass-A", "development", dev_after),
"old passphrase must NOT decrypt the new envelope"
);
}
#[test]
fn rotate_env_wrong_current_passphrase_leaves_artifact_unchanged() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let (vault, pid) = open_test_vault(&tmp);
crate::core::set_secret(&vault, &TEST_KEY, &pid, "development", "API_KEY", "secret")
.expect("set_secret must succeed");
let mut artifact = seal_artifact(&vault, &TEST_KEY, &pid, "pass-A", Some(&["development"]))
.expect("seal_artifact must succeed");
let dev_before = artifact.environments["development"].clone();
let staging_before = make_envelope("pass-X", "OTHER", "value");
artifact
.environments
.insert("staging".to_string(), staging_before.clone());
let result = rotate_env(
&vault,
&TEST_KEY,
&pid,
&mut artifact,
"development",
"WRONG-pass",
"pass-B",
);
assert!(
matches!(
result,
Err(SyncError::Artifact(
crate::crypto::artifact::ArtifactError::MalformedEnvelope(_, _)
))
),
"wrong current passphrase must return MalformedEnvelope, got: {:?}",
result.err()
);
let dev_after = &artifact.environments["development"];
assert_eq!(
dev_after.ciphertext, dev_before.ciphertext,
"ciphertext must be unchanged after wrong-pass attempt"
);
assert_eq!(
dev_after.nonce, dev_before.nonce,
"nonce must be unchanged after wrong-pass attempt"
);
assert_eq!(
artifact.environments["staging"].ciphertext, staging_before.ciphertext,
"other envelopes must be unchanged"
);
}
}