use anyhow::{Context, Result};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
use crate::config::{CloudFileFormat, CloudFileRemoteConfig, Config};
use crate::store::{decrypt_with_key, derive_key, encrypt_with_key, SecretStore, StorePayload};
use super::SyncRemote;
const CLOUD_SYNC_DOMAIN: &[u8] = b"esk-cloud-sync-v1";
pub struct CloudFileRemote {
name: String,
project: String,
remote_config: CloudFileRemoteConfig,
}
impl CloudFileRemote {
pub fn new(name: String, project: String, remote_config: CloudFileRemoteConfig) -> Self {
Self {
name,
project,
remote_config,
}
}
fn expand_path(&self) -> Result<PathBuf> {
let path = self.remote_config.path.replace("{project}", &self.project);
if let Some(rest) = path.strip_prefix("~/") {
let home = std::env::var("HOME").context("HOME environment variable not set")?;
Ok(PathBuf::from(home).join(rest))
} else {
Ok(PathBuf::from(path))
}
}
fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
let dir = path.parent().context("path has no parent")?;
let tmp = NamedTempFile::new_in(dir)?;
std::fs::write(tmp.path(), content)?;
tmp.persist(path)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
}
impl SyncRemote for CloudFileRemote {
fn name(&self) -> &str {
&self.name
}
fn uses_cleartext_format(&self) -> bool {
matches!(
self.remote_config.format,
crate::config::CloudFileFormat::Cleartext
)
}
fn preflight(&self) -> Result<()> {
let path = self.expand_path()?;
if !path.is_dir() {
std::fs::create_dir_all(&path).with_context(|| {
format!(
"failed to create {} sync folder at {}",
self.name,
path.display()
)
})?;
}
let probe = path.join(".esk-probe");
std::fs::write(&probe, b"").map_err(|e| {
anyhow::anyhow!(
"{} sync folder at {} is not writable: {e}",
self.name,
path.display()
)
})?;
let _ = std::fs::remove_file(&probe);
Ok(())
}
fn push(&self, payload: &StorePayload, config: &Config, env: &str) -> Result<()> {
let base_path = self.expand_path()?;
let env_payload = payload.for_env(env);
match self.remote_config.format {
CloudFileFormat::Encrypted => {
let store = SecretStore::open(&config.root)?;
let dk = derive_key(store.master_key(), CLOUD_SYNC_DOMAIN);
let json = serde_json::to_string(&env_payload)
.context("failed to serialize env payload")?;
let encrypted = encrypt_with_key(&dk, &json)?;
let dest = base_path.join(format!("secrets-{env}.enc"));
Self::atomic_write(&dest, encrypted.as_bytes())?;
}
CloudFileFormat::Cleartext => {
let dest = base_path.join(format!("secrets-{env}.json"));
let json = serde_json::to_string_pretty(&env_payload)
.context("failed to serialize env payload")?;
Self::atomic_write(&dest, json.as_bytes())?;
}
}
Ok(())
}
fn pull(&self, config: &Config, env: &str) -> Result<Option<(BTreeMap<String, String>, u64)>> {
let base_path = self.expand_path()?;
match self.remote_config.format {
CloudFileFormat::Encrypted => {
let per_env = base_path.join(format!("secrets-{env}.enc"));
if !per_env.is_file() {
return Ok(None);
}
let content = std::fs::read_to_string(&per_env)
.with_context(|| format!("failed to read {}", per_env.display()))?;
let content = content.trim();
if content.is_empty() {
return Ok(None);
}
let store = SecretStore::open(&config.root)?;
let dk = derive_key(store.master_key(), CLOUD_SYNC_DOMAIN);
let json = decrypt_with_key(&dk, content)?;
let payload: StorePayload =
serde_json::from_str(&json).context("decrypted payload is not valid JSON")?;
Ok(Some((
StorePayload::bare_to_composite(&payload.secrets, env),
payload.version,
)))
}
CloudFileFormat::Cleartext => {
let per_env = base_path.join(format!("secrets-{env}.json"));
if !per_env.is_file() {
return Ok(None);
}
let content = std::fs::read_to_string(&per_env)
.with_context(|| format!("failed to read {}", per_env.display()))?;
let payload: StorePayload =
serde_json::from_str(&content).context("failed to parse secrets JSON")?;
Ok(Some((
StorePayload::bare_to_composite(&payload.secrets, env),
payload.version,
)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::SecretStore;
fn make_config_with_store(dir: &Path) -> Config {
let yaml = "project: testapp\nenvironments: [dev, prod]";
let path = dir.join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
SecretStore::load_or_create(dir).unwrap();
Config::load(&path).unwrap()
}
fn make_payload(secrets: &[(&str, &str)], version: u64) -> StorePayload {
let mut map = BTreeMap::new();
for (k, v) in secrets {
map.insert((*k).to_string(), (*v).to_string());
}
StorePayload {
secrets: map,
version,
..Default::default()
}
}
#[test]
fn cloud_file_preflight_success() {
let cloud_dir = tempfile::tempdir().unwrap();
let remote = CloudFileRemote::new(
"dropbox".to_string(),
"testapp".to_string(),
CloudFileRemoteConfig {
path: cloud_dir.path().to_string_lossy().to_string(),
format: CloudFileFormat::Cleartext,
},
);
assert!(remote.preflight().is_ok());
}
#[test]
fn cloud_file_preflight_not_writable() {
use std::os::unix::fs::PermissionsExt;
let cloud_dir = tempfile::tempdir().unwrap();
let readonly = cloud_dir.path().join("readonly");
std::fs::create_dir(&readonly).unwrap();
std::fs::set_permissions(&readonly, std::fs::Permissions::from_mode(0o444)).unwrap();
let remote = CloudFileRemote::new(
"dropbox".to_string(),
"testapp".to_string(),
CloudFileRemoteConfig {
path: readonly.to_string_lossy().to_string(),
format: CloudFileFormat::Cleartext,
},
);
let err = remote.preflight().unwrap_err();
assert!(err.to_string().contains("not writable"));
std::fs::set_permissions(&readonly, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn cloud_file_preflight_creates_missing_dir() {
let base = tempfile::tempdir().unwrap();
let nested = base.path().join("deep/nested/sync");
let remote = CloudFileRemote::new(
"dropbox".to_string(),
"testapp".to_string(),
CloudFileRemoteConfig {
path: nested.to_string_lossy().to_string(),
format: CloudFileFormat::Cleartext,
},
);
assert!(remote.preflight().is_ok());
assert!(nested.is_dir());
}
#[test]
fn cleartext_push_pull_roundtrip() {
let project_dir = tempfile::tempdir().unwrap();
let cloud_dir = tempfile::tempdir().unwrap();
let config = make_config_with_store(project_dir.path());
let remote = CloudFileRemote::new(
"test_cloud".to_string(),
"testapp".to_string(),
CloudFileRemoteConfig {
path: cloud_dir.path().to_string_lossy().to_string(),
format: CloudFileFormat::Cleartext,
},
);
let payload = make_payload(&[("KEY:dev", "val1"), ("KEY:prod", "val2")], 5);
remote.push(&payload, &config, "dev").unwrap();
assert!(cloud_dir.path().join("secrets-dev.json").is_file());
assert!(!cloud_dir.path().join("secrets.json").is_file());
let (secrets, version) = remote.pull(&config, "dev").unwrap().unwrap();
assert_eq!(version, 5);
assert_eq!(secrets.get("KEY:dev").unwrap(), "val1");
assert!(!secrets.contains_key("KEY:prod"));
}
#[test]
fn encrypted_push_pull_roundtrip() {
let project_dir = tempfile::tempdir().unwrap();
let cloud_dir = tempfile::tempdir().unwrap();
let config = make_config_with_store(project_dir.path());
let store = SecretStore::open(&config.root).unwrap();
store.set("KEY", "dev", "encrypted_val").unwrap();
let remote = CloudFileRemote::new(
"test_enc".to_string(),
"testapp".to_string(),
CloudFileRemoteConfig {
path: cloud_dir.path().to_string_lossy().to_string(),
format: CloudFileFormat::Encrypted,
},
);
let payload = store.payload().unwrap();
remote.push(&payload, &config, "dev").unwrap();
assert!(cloud_dir.path().join("secrets-dev.enc").is_file());
assert!(!cloud_dir.path().join("secrets.enc").is_file());
let (secrets, version) = remote.pull(&config, "dev").unwrap().unwrap();
assert_eq!(version, 1);
assert_eq!(secrets.get("KEY:dev").unwrap(), "encrypted_val");
}
#[test]
fn per_env_isolation() {
let project_dir = tempfile::tempdir().unwrap();
let cloud_dir = tempfile::tempdir().unwrap();
let config = make_config_with_store(project_dir.path());
let remote = CloudFileRemote::new(
"test".to_string(),
"testapp".to_string(),
CloudFileRemoteConfig {
path: cloud_dir.path().to_string_lossy().to_string(),
format: CloudFileFormat::Cleartext,
},
);
let payload = make_payload(&[("KEY:dev", "dev_val"), ("KEY:prod", "prod_val")], 5);
remote.push(&payload, &config, "dev").unwrap();
remote.push(&payload, &config, "prod").unwrap();
let (dev_secrets, _) = remote.pull(&config, "dev").unwrap().unwrap();
assert_eq!(dev_secrets.get("KEY:dev").unwrap(), "dev_val");
assert!(!dev_secrets.contains_key("KEY:prod"));
let (prod_secrets, _) = remote.pull(&config, "prod").unwrap().unwrap();
assert_eq!(prod_secrets.get("KEY:prod").unwrap(), "prod_val");
assert!(!prod_secrets.contains_key("KEY:dev"));
}
#[test]
fn pull_nonexistent_returns_none() {
let project_dir = tempfile::tempdir().unwrap();
let cloud_dir = tempfile::tempdir().unwrap();
let config = make_config_with_store(project_dir.path());
let remote = CloudFileRemote::new(
"test".to_string(),
"testapp".to_string(),
CloudFileRemoteConfig {
path: cloud_dir.path().to_string_lossy().to_string(),
format: CloudFileFormat::Cleartext,
},
);
assert!(remote.pull(&config, "dev").unwrap().is_none());
}
#[test]
fn pull_encrypted_nonexistent_returns_none() {
let project_dir = tempfile::tempdir().unwrap();
let cloud_dir = tempfile::tempdir().unwrap();
let config = make_config_with_store(project_dir.path());
let remote = CloudFileRemote::new(
"test".to_string(),
"testapp".to_string(),
CloudFileRemoteConfig {
path: cloud_dir.path().to_string_lossy().to_string(),
format: CloudFileFormat::Encrypted,
},
);
assert!(remote.pull(&config, "dev").unwrap().is_none());
}
#[test]
fn push_creates_parent_dirs() {
let project_dir = tempfile::tempdir().unwrap();
let cloud_dir = tempfile::tempdir().unwrap();
let nested = cloud_dir.path().join("deep/nested/path");
let config = make_config_with_store(project_dir.path());
let remote = CloudFileRemote::new(
"test".to_string(),
"testapp".to_string(),
CloudFileRemoteConfig {
path: nested.to_string_lossy().to_string(),
format: CloudFileFormat::Cleartext,
},
);
let payload = make_payload(&[("A:dev", "1")], 1);
remote.push(&payload, &config, "dev").unwrap();
assert!(nested.join("secrets-dev.json").is_file());
}
#[test]
fn tilde_expansion() {
let remote = CloudFileRemote::new(
"test".to_string(),
"testapp".to_string(),
CloudFileRemoteConfig {
path: "~/test/path".to_string(),
format: CloudFileFormat::Cleartext,
},
);
let expanded = remote.expand_path().unwrap();
assert!(!expanded.to_string_lossy().contains('~'));
assert!(expanded.to_string_lossy().ends_with("/test/path"));
}
#[test]
fn no_tilde_expansion_for_absolute() {
let remote = CloudFileRemote::new(
"test".to_string(),
"testapp".to_string(),
CloudFileRemoteConfig {
path: "/absolute/path".to_string(),
format: CloudFileFormat::Cleartext,
},
);
let expanded = remote.expand_path().unwrap();
assert_eq!(expanded, PathBuf::from("/absolute/path"));
}
#[test]
fn project_interpolation() {
let remote = CloudFileRemote::new(
"test".to_string(),
"myapp".to_string(),
CloudFileRemoteConfig {
path: "/cloud/esk/{project}".to_string(),
format: CloudFileFormat::Cleartext,
},
);
let expanded = remote.expand_path().unwrap();
assert_eq!(expanded, PathBuf::from("/cloud/esk/myapp"));
}
#[test]
fn project_interpolation_with_tilde() {
let remote = CloudFileRemote::new(
"test".to_string(),
"myapp".to_string(),
CloudFileRemoteConfig {
path: "~/Dropbox/esk/{project}".to_string(),
format: CloudFileFormat::Encrypted,
},
);
let expanded = remote.expand_path().unwrap();
assert!(!expanded.to_string_lossy().contains('~'));
assert!(!expanded.to_string_lossy().contains("{project}"));
assert!(expanded.to_string_lossy().ends_with("/Dropbox/esk/myapp"));
}
}