1use base64::Engine;
2use base64::engine::general_purpose::STANDARD_NO_PAD;
3use fs2::FileExt;
4use greentic_secrets_spec::{
5 KeyProvider, Scope, SecretListItem, SecretRecord, SecretUri, SecretVersion, SecretsBackend,
6 SecretsError as Error, SecretsResult as Result, VersionedSecret,
7};
8use parking_lot::RwLock;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use std::collections::BTreeMap;
12use std::fs::OpenOptions;
13use std::io::{BufRead, BufReader, BufWriter, Seek, SeekFrom, Write};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use tempfile::NamedTempFile;
17
18const DEFAULT_PERSIST_PATH: &str = ".dev.secrets.env";
19const PERSIST_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
20const ENV_KEY: &str = "SECRETS_BACKEND_STATE";
21const MASTER_KEY_ENV: &str = "GREENTIC_DEV_MASTER_KEY";
22
23#[derive(Clone, Default)]
25pub struct DevKeyProvider {
26 master_key: [u8; 32],
27}
28
29impl DevKeyProvider {
30 pub fn from_env() -> Self {
32 let material = std::env::var(MASTER_KEY_ENV).unwrap_or_default();
33 Self::from_material(material.as_bytes())
34 }
35
36 pub fn from_material(input: &[u8]) -> Self {
38 let mut hasher = Sha256::new();
39 hasher.update(input);
40 let digest = hasher.finalize();
41 let mut master_key = [0u8; 32];
42 master_key.copy_from_slice(&digest);
43 Self { master_key }
44 }
45}
46
47impl KeyProvider for DevKeyProvider {
48 fn wrap_dek(&self, _scope: &Scope, dek: &[u8]) -> Result<Vec<u8>> {
49 Ok(xor_with_key(dek, &self.master_key))
50 }
51
52 fn unwrap_dek(&self, _scope: &Scope, wrapped: &[u8]) -> Result<Vec<u8>> {
53 Ok(xor_with_key(wrapped, &self.master_key))
54 }
55}
56
57fn xor_with_key(input: &[u8], key: &[u8; 32]) -> Vec<u8> {
58 input
59 .iter()
60 .enumerate()
61 .map(|(idx, byte)| byte ^ key[idx % key.len()])
62 .collect()
63}
64
65fn paths_alias(a: &Path, b: &Path) -> bool {
69 if a == b {
70 return true;
71 }
72 matches!((a.canonicalize(), b.canonicalize()), (Ok(ca), Ok(cb)) if ca == cb)
73}
74
75fn versionless_key(key: &str) -> Option<String> {
79 SecretUri::parse(key)
80 .ok()?
81 .with_version(None)
82 .ok()
83 .map(|uri| uri.to_string())
84}
85
86fn versionless_keys(uris: &[&SecretUri]) -> Result<Vec<String>> {
88 uris.iter()
89 .map(|uri| {
90 (**uri)
91 .clone()
92 .with_version(None)
93 .map(|normalized| normalized.to_string())
94 .map_err(|err| Error::Storage(err.to_string()))
95 })
96 .collect()
97}
98
99#[derive(Clone, Default)]
100struct State {
101 entries: BTreeMap<String, Vec<VersionEntry>>,
102}
103
104#[derive(Clone, Serialize, Deserialize)]
105struct VersionEntry {
106 version: u64,
107 deleted: bool,
108 record: Option<SecretRecord>,
109}
110
111impl VersionEntry {
112 fn live(version: u64, record: SecretRecord) -> Self {
113 Self {
114 version,
115 deleted: false,
116 record: Some(record),
117 }
118 }
119
120 fn tombstone(version: u64) -> Self {
121 Self {
122 version,
123 deleted: true,
124 record: None,
125 }
126 }
127
128 fn as_version(&self) -> SecretVersion {
129 SecretVersion {
130 version: self.version,
131 deleted: self.deleted,
132 }
133 }
134
135 fn as_versioned(&self) -> VersionedSecret {
136 VersionedSecret {
137 version: self.version,
138 deleted: self.deleted,
139 record: self.record.clone(),
140 }
141 }
142}
143
144#[derive(Clone)]
145struct Persistence {
146 path: PathBuf,
147}
148
149impl Persistence {
150 fn read_state(file: &std::fs::File) -> Result<State> {
154 let reader = BufReader::new(file);
155 for line in reader.lines() {
156 let line = line.map_err(|err| Error::Storage(err.to_string()))?;
157 if line.trim().is_empty() || line.starts_with('#') {
158 continue;
159 }
160
161 if let Some((key, value)) = line.split_once('=')
162 && key.trim() == ENV_KEY
163 {
164 let decoded = STANDARD_NO_PAD
165 .decode(value.trim())
166 .map_err(|err| Error::Storage(err.to_string()))?;
167 let persisted: PersistedState = serde_json::from_slice(&decoded)
168 .map_err(|err| Error::Storage(err.to_string()))?;
169 return Ok(persisted.into_state());
170 }
171 }
172 Ok(State::default())
173 }
174
175 fn load(path: PathBuf) -> Result<(State, Self)> {
176 let file = OpenOptions::new()
177 .read(true)
178 .write(true)
179 .create(true)
180 .truncate(false)
181 .open(&path)
182 .map_err(|err| Error::Storage(err.to_string()))?;
183
184 file.lock_exclusive()
185 .map_err(|err| Error::Storage(err.to_string()))?;
186
187 let result = Self::read_state(&file);
188
189 let _ = fs2::FileExt::unlock(&file);
190 result.map(|state| (state, Self { path }))
191 }
192
193 fn snapshot(path: &Path) -> Result<State> {
200 let file = OpenOptions::new()
201 .read(true)
202 .open(path)
203 .map_err(|err| Error::Storage(err.to_string()))?;
204 file.lock_shared()
205 .map_err(|err| Error::Storage(err.to_string()))?;
206 let result = Self::read_state(&file);
207 let _ = fs2::FileExt::unlock(&file);
208 result
209 }
210
211 fn persist(&self, state: &State) -> Result<()> {
212 let mut file = OpenOptions::new()
213 .read(true)
214 .write(true)
215 .create(true)
216 .truncate(false)
217 .open(&self.path)
218 .map_err(|err| Error::Storage(err.to_string()))?;
219
220 file.lock_exclusive()
221 .map_err(|err| Error::Storage(err.to_string()))?;
222
223 let result = (|| -> Result<()> {
224 file.set_len(0)
225 .map_err(|err| Error::Storage(err.to_string()))?;
226 file.seek(SeekFrom::Start(0))
227 .map_err(|err| Error::Storage(err.to_string()))?;
228
229 let persisted = PersistedState::from_state(state);
230 let json =
231 serde_json::to_vec(&persisted).map_err(|err| Error::Storage(err.to_string()))?;
232 let encoded = STANDARD_NO_PAD.encode(json);
233
234 let mut writer = BufWriter::new(&file);
235 writer
236 .write_all(format!("{ENV_KEY}={encoded}\n").as_bytes())
237 .map_err(|err| Error::Storage(err.to_string()))?;
238 writer
239 .flush()
240 .map_err(|err| Error::Storage(err.to_string()))?;
241 Ok(())
242 })();
243
244 let _ = fs2::FileExt::unlock(&file);
245 result
246 }
247}
248
249#[derive(Serialize, Deserialize)]
250struct PersistedState {
251 secrets: Vec<PersistedSecret>,
252}
253
254impl PersistedState {
255 fn from_state(state: &State) -> Self {
256 let secrets = state
257 .entries
258 .iter()
259 .map(|(key, versions)| PersistedSecret {
260 key: key.clone(),
261 versions: versions.clone(),
262 })
263 .collect();
264 Self { secrets }
265 }
266
267 fn into_state(self) -> State {
268 let mut entries = BTreeMap::new();
269 for secret in self.secrets {
270 entries.insert(secret.key, secret.versions);
271 }
272 State { entries }
273 }
274}
275
276#[derive(Serialize, Deserialize)]
277struct PersistedSecret {
278 key: String,
279 versions: Vec<VersionEntry>,
280}
281
282#[derive(Clone)]
284pub struct DevBackend {
285 state: Arc<RwLock<State>>,
286 persistence: Option<Persistence>,
287}
288
289impl Default for DevBackend {
290 fn default() -> Self {
291 Self::new()
292 }
293}
294
295impl DevBackend {
296 pub fn new() -> Self {
298 Self {
299 state: Arc::new(RwLock::new(State::default())),
300 persistence: None,
301 }
302 }
303
304 pub fn with_persistence<P: Into<PathBuf>>(path: P) -> Result<Self> {
306 let path = path.into();
307 let (state, persistence) = Persistence::load(path)?;
308 Ok(Self {
309 state: Arc::new(RwLock::new(state)),
310 persistence: Some(persistence),
311 })
312 }
313
314 pub fn from_env() -> Result<Self> {
317 if let Ok(path) = std::env::var(PERSIST_ENV) {
318 return Self::with_persistence(PathBuf::from(path));
319 }
320
321 let default_path = PathBuf::from(DEFAULT_PERSIST_PATH);
322 if default_path.exists() {
323 Self::with_persistence(default_path)
324 } else {
325 Ok(Self::new())
326 }
327 }
328
329 pub fn export_excluding(src: &Path, dest: &Path, exclude: &[&SecretUri]) -> Result<()> {
367 if paths_alias(src, dest) {
373 return Err(Error::Storage(
374 "export destination must differ from the source store".to_string(),
375 ));
376 }
377
378 let mut state = Persistence::snapshot(src)?;
381
382 let excluded = versionless_keys(exclude)?;
388 state
389 .entries
390 .retain(|stored_key, _| match versionless_key(stored_key) {
391 Some(canonical) => !excluded.contains(&canonical),
392 None => true,
396 });
397
398 let persisted = PersistedState::from_state(&state);
399 let json = serde_json::to_vec(&persisted).map_err(|err| Error::Storage(err.to_string()))?;
400 let encoded = STANDARD_NO_PAD.encode(json);
401
402 let dir = dest
406 .parent()
407 .filter(|parent| !parent.as_os_str().is_empty())
408 .unwrap_or_else(|| Path::new("."));
409 let mut tmp = NamedTempFile::new_in(dir).map_err(|err| Error::Storage(err.to_string()))?;
410 tmp.write_all(format!("{ENV_KEY}={encoded}\n").as_bytes())
411 .map_err(|err| Error::Storage(err.to_string()))?;
412 tmp.as_file()
413 .sync_all()
414 .map_err(|err| Error::Storage(err.to_string()))?;
415 tmp.persist_noclobber(dest)
416 .map_err(|err| Error::Storage(err.to_string()))?;
417 Ok(())
418 }
419
420 fn persist_if_needed(&self, state: State) -> Result<()> {
421 if let Some(persistence) = &self.persistence {
422 persistence.persist(&state)?;
423 }
424 Ok(())
425 }
426}
427
428impl SecretsBackend for DevBackend {
429 fn put(&self, record: SecretRecord) -> Result<SecretVersion> {
430 let key = record.meta.uri.to_string();
431 let mut state_guard = self.state.write();
432 let versions = state_guard.entries.entry(key).or_default();
433 let next_version = versions.last().map(|v| v.version + 1).unwrap_or(1);
434
435 versions.push(VersionEntry::live(next_version, record));
436 let snapshot = if self.persistence.is_some() {
437 Some(state_guard.clone())
438 } else {
439 None
440 };
441 drop(state_guard);
442
443 if let Some(state) = snapshot {
444 self.persist_if_needed(state)?;
445 }
446
447 Ok(SecretVersion {
448 version: next_version,
449 deleted: false,
450 })
451 }
452
453 fn get(&self, uri: &SecretUri, version: Option<u64>) -> Result<Option<VersionedSecret>> {
454 let key = uri.to_string();
455 let state = self.state.read();
456 let versions = match state.entries.get(&key) {
457 Some(versions) => versions,
458 None => return Ok(None),
459 };
460
461 if let Some(target) = version {
462 let entry = versions.iter().find(|entry| entry.version == target);
463 return Ok(entry.cloned().map(|entry| entry.as_versioned()));
464 }
465
466 if matches!(versions.last(), Some(entry) if entry.deleted) {
467 return Ok(None);
468 }
469
470 let latest = versions.iter().rev().find(|entry| !entry.deleted).cloned();
471 Ok(latest.map(|entry| entry.as_versioned()))
472 }
473
474 fn list(
475 &self,
476 scope: &Scope,
477 category_prefix: Option<&str>,
478 name_prefix: Option<&str>,
479 ) -> Result<Vec<SecretListItem>> {
480 let state = self.state.read();
481 let mut items = Vec::new();
482
483 for versions in state.entries.values() {
484 if matches!(versions.last(), Some(entry) if entry.deleted) {
485 continue;
486 }
487
488 let latest = match versions.iter().rev().find(|entry| !entry.deleted) {
489 Some(entry) => entry,
490 None => continue,
491 };
492
493 let record = match &latest.record {
494 Some(record) => record,
495 None => continue,
496 };
497
498 let secret_scope = record.meta.scope();
499 if scope.env() != secret_scope.env() || scope.tenant() != secret_scope.tenant() {
500 continue;
501 }
502 if scope.team() != secret_scope.team() {
503 continue;
504 }
505
506 if let Some(prefix) = category_prefix
507 && !record.meta.uri.category().starts_with(prefix)
508 {
509 continue;
510 }
511
512 if let Some(prefix) = name_prefix
513 && !record.meta.uri.name().starts_with(prefix)
514 {
515 continue;
516 }
517
518 items.push(SecretListItem::from_meta(
519 &record.meta,
520 Some(latest.version.to_string()),
521 ));
522 }
523
524 items.sort_by_key(|a| a.uri.to_string());
525 Ok(items)
526 }
527
528 fn delete(&self, uri: &SecretUri) -> Result<SecretVersion> {
529 let key = uri.to_string();
530 let mut state_guard = self.state.write();
531 let versions = match state_guard.entries.get_mut(&key) {
532 Some(versions) => versions,
533 None => {
534 return Err(Error::NotFound {
535 entity: uri.to_string(),
536 });
537 }
538 };
539
540 let has_live = versions.iter().any(|entry| !entry.deleted);
541 if !has_live {
542 return Err(Error::NotFound {
543 entity: uri.to_string(),
544 });
545 }
546
547 let next_version = versions.last().map(|v| v.version + 1).unwrap_or(1);
548 versions.push(VersionEntry::tombstone(next_version));
549 let snapshot = if self.persistence.is_some() {
550 Some(state_guard.clone())
551 } else {
552 None
553 };
554 drop(state_guard);
555
556 if let Some(state) = snapshot {
557 self.persist_if_needed(state)?;
558 }
559
560 Ok(SecretVersion {
561 version: next_version,
562 deleted: true,
563 })
564 }
565
566 fn versions(&self, uri: &SecretUri) -> Result<Vec<SecretVersion>> {
567 let key = uri.to_string();
568 let state = self.state.read();
569 let versions = match state.entries.get(&key) {
570 Some(versions) => versions,
571 None => return Ok(Vec::new()),
572 };
573
574 Ok(versions.iter().map(|entry| entry.as_version()).collect())
575 }
576
577 fn exists(&self, uri: &SecretUri) -> Result<bool> {
578 let key = uri.to_string();
579 let state = self.state.read();
580 let versions = match state.entries.get(&key) {
581 Some(versions) => versions,
582 None => return Ok(false),
583 };
584
585 Ok(matches!(versions.last(), Some(entry) if !entry.deleted))
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use greentic_secrets_spec::{
593 ContentType, EncryptionAlgorithm, Envelope, SecretMeta, Visibility,
594 };
595 use serde_json::json;
596 use std::fs;
597 use std::process::Command;
598 use std::thread;
599 use std::time::{Duration, SystemTime, UNIX_EPOCH};
600
601 const PERSIST_CHILD_ENV: &str = "GREENTIC_DEV_PERSIST_CHILD";
602
603 fn sample_scope() -> Scope {
604 Scope::new("dev", "acme", Some("payments".into())).unwrap()
605 }
606
607 fn sample_uri(scope: &Scope, category: &str, name: &str) -> SecretUri {
608 SecretUri::new(scope.clone(), category, name).unwrap()
609 }
610
611 fn record(uri: &SecretUri, content_type: ContentType, payload: Vec<u8>) -> SecretRecord {
612 let meta = SecretMeta::new(uri.clone(), Visibility::Team, content_type);
613 let envelope = Envelope {
614 algorithm: EncryptionAlgorithm::Aes256Gcm,
615 nonce: Vec::new(),
616 hkdf_salt: Vec::new(),
617 wrapped_dek: Vec::new(),
618 };
619 SecretRecord::new(meta, payload, envelope)
620 }
621
622 #[test]
623 fn backend_put_get_latest_and_versioned() {
624 let backend = DevBackend::new();
625 let scope = sample_scope();
626 let uri = sample_uri(&scope, "kv", "db-password");
627
628 let payload_v1 = serde_json::to_vec(&json!({"password": "s3cr3t"})).unwrap();
629 let v1 = backend
630 .put(record(&uri, ContentType::Json, payload_v1.clone()))
631 .unwrap();
632 assert_eq!(v1.version, 1);
633
634 let latest = backend.get(&uri, None).unwrap().expect("latest record");
635 assert_eq!(latest.version, 1);
636 let stored = latest.record.expect("record payload");
637 assert_eq!(stored.value, payload_v1);
638 assert_eq!(stored.meta.content_type, ContentType::Json);
639
640 let payload_v2 = serde_json::to_vec(&json!({"password": "n3w"})).unwrap();
641 let v2 = backend
642 .put(record(&uri, ContentType::Json, payload_v2.clone()))
643 .unwrap();
644 assert_eq!(v2.version, 2);
645
646 let latest = backend.get(&uri, None).unwrap().expect("latest record");
647 assert_eq!(latest.version, 2);
648 let stored = latest.record.expect("record payload");
649 assert_eq!(stored.value, payload_v2);
650
651 let version_one = backend.get(&uri, Some(1)).unwrap().expect("v1 record");
652 assert_eq!(version_one.version, 1);
653 let stored = version_one.record.expect("record payload");
654 assert_eq!(
655 stored.value,
656 serde_json::to_vec(&json!({"password": "s3cr3t"})).unwrap()
657 );
658 }
659
660 #[test]
661 fn list_with_prefix() {
662 let backend = DevBackend::new();
663 let scope = sample_scope();
664 let uri_api = sample_uri(&scope, "kv", "api-token");
665 let uri_db = sample_uri(&scope, "kv", "db-password");
666 let uri_cfg = sample_uri(&scope, "config", "feature-flags");
667
668 backend
669 .put(record(&uri_api, ContentType::Opaque, b"api".to_vec()))
670 .unwrap();
671 backend
672 .put(record(&uri_db, ContentType::Text, b"db".to_vec()))
673 .unwrap();
674 backend
675 .put(record(
676 &uri_cfg,
677 ContentType::Json,
678 serde_json::to_vec(&json!({"feature": true})).unwrap(),
679 ))
680 .unwrap();
681
682 let kv = backend.list(&scope, Some("kv"), None).unwrap();
683 assert_eq!(kv.len(), 2);
684
685 let api_only = backend.list(&scope, Some("kv"), Some("api")).unwrap();
686 assert_eq!(api_only.len(), 1);
687 assert!(api_only[0].uri.to_string().contains("api-token"));
688 }
689
690 #[test]
691 fn delete_and_restore() {
692 let backend = DevBackend::new();
693 let scope = sample_scope();
694 let uri = sample_uri(&scope, "kv", "session-key");
695
696 backend
697 .put(record(&uri, ContentType::Binary, vec![0x01, 0x02, 0x03]))
698 .unwrap();
699
700 assert!(backend.exists(&uri).unwrap());
701 backend.delete(&uri).unwrap();
702 assert!(!backend.exists(&uri).unwrap());
703 assert!(backend.get(&uri, None).unwrap().is_none());
704
705 backend
706 .put(record(&uri, ContentType::Binary, vec![0xAA, 0xBB]))
707 .unwrap();
708
709 let latest = backend.get(&uri, None).unwrap().expect("restored");
710 let record = latest.record.expect("record payload");
711 assert_eq!(record.value, vec![0xAA, 0xBB]);
712 assert!(backend.exists(&uri).unwrap());
713 }
714
715 #[test]
716 fn content_types_round_trip() {
717 let backend = DevBackend::new();
718 let scope = sample_scope();
719
720 let text_uri = sample_uri(&scope, "kv", "text");
721 let bin_uri = sample_uri(&scope, "kv", "bin");
722
723 backend
724 .put(record(
725 &text_uri,
726 ContentType::Text,
727 b"hello world".to_vec(),
728 ))
729 .unwrap();
730 backend
731 .put(record(&bin_uri, ContentType::Binary, vec![0, 1, 2, 3]))
732 .unwrap();
733
734 let text_record = backend
735 .get(&text_uri, None)
736 .unwrap()
737 .unwrap()
738 .record
739 .unwrap();
740 assert_eq!(text_record.meta.content_type, ContentType::Text);
741 assert_eq!(text_record.value, b"hello world".to_vec());
742
743 let bin_record = backend
744 .get(&bin_uri, None)
745 .unwrap()
746 .unwrap()
747 .record
748 .unwrap();
749 assert_eq!(bin_record.meta.content_type, ContentType::Binary);
750 assert_eq!(bin_record.value, vec![0, 1, 2, 3]);
751 }
752
753 #[test]
754 fn key_provider_wrap_unwrap() {
755 let provider = DevKeyProvider::from_material(b"material");
756 let scope = sample_scope();
757 let dek = vec![1, 2, 3, 4, 5];
758 let wrapped = provider.wrap_dek(&scope, &dek).unwrap();
759 assert_eq!(wrapped.len(), dek.len());
760 assert_ne!(wrapped, dek);
761 let unwrapped = provider.unwrap_dek(&scope, &wrapped).unwrap();
762 assert_eq!(unwrapped, dek);
763 }
764
765 #[test]
766 fn persistence_does_not_truncate_before_lock() {
767 if let Some(path) = std::env::var_os(PERSIST_CHILD_ENV) {
768 Persistence {
769 path: PathBuf::from(path),
770 }
771 .persist(&State::default())
772 .unwrap();
773 return;
774 }
775
776 let temp = std::env::temp_dir().join(format!(
777 "greentic-dev-persist-test-{}-{}",
778 std::process::id(),
779 SystemTime::now()
780 .duration_since(UNIX_EPOCH)
781 .unwrap()
782 .as_nanos()
783 ));
784 fs::create_dir(&temp).unwrap();
785 let path = temp.join(".dev.secrets.env");
786 let original = format!("{ENV_KEY}=eyJzZWNyZXRzIjpbXX0\n");
787 fs::write(&path, &original).unwrap();
788
789 let locked = OpenOptions::new()
790 .read(true)
791 .write(true)
792 .open(&path)
793 .unwrap();
794 locked.lock_exclusive().unwrap();
795
796 let mut child = Command::new(std::env::current_exe().unwrap())
797 .arg("persistence_does_not_truncate_before_lock")
798 .arg("--exact")
799 .env(PERSIST_CHILD_ENV, &path)
800 .spawn()
801 .unwrap();
802
803 thread::sleep(Duration::from_millis(250));
804 assert_eq!(fs::read_to_string(&path).unwrap(), original);
805
806 fs2::FileExt::unlock(&locked).unwrap();
807 let status = child.wait().unwrap();
808 assert!(status.success());
809
810 DevBackend::with_persistence(&path).unwrap();
811 fs::remove_dir_all(&temp).unwrap();
812 }
813
814 fn persisted_keys(path: &std::path::Path) -> Vec<String> {
818 let contents = fs::read_to_string(path).unwrap();
819 let encoded = contents
820 .lines()
821 .find_map(|line| line.trim().strip_prefix(&format!("{ENV_KEY}=")))
822 .expect("persisted state line");
823 let bytes = STANDARD_NO_PAD.decode(encoded.trim()).unwrap();
824 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
825 json["secrets"]
826 .as_array()
827 .unwrap()
828 .iter()
829 .map(|secret| secret["key"].as_str().unwrap().to_string())
830 .collect()
831 }
832
833 fn unique_temp_dir(tag: &str) -> PathBuf {
834 let dir = std::env::temp_dir().join(format!(
835 "greentic-dev-{tag}-{}-{}",
836 std::process::id(),
837 SystemTime::now()
838 .duration_since(UNIX_EPOCH)
839 .unwrap()
840 .as_nanos()
841 ));
842 fs::create_dir(&dir).unwrap();
843 dir
844 }
845
846 fn seed_store(path: &Path, entries: &[(&SecretUri, &[u8])]) {
847 let backend = DevBackend::with_persistence(path).unwrap();
848 for (uri, payload) in entries {
849 backend
850 .put(record(uri, ContentType::Text, payload.to_vec()))
851 .unwrap();
852 }
853 }
854
855 #[test]
856 fn export_excluding_drops_only_excluded_keys_and_leaves_src_intact() {
857 let temp = unique_temp_dir("export");
858 let src = temp.join(".dev.secrets.env");
859 let dest = temp.join(".seed.secrets.env");
860 let scope = sample_scope();
861 let cred = sample_uri(&scope, "kv", "deployer-credential");
862 let runtime = sample_uri(&scope, "kv", "runtime-token");
863 seed_store(&src, &[(&cred, b"SA-KEY"), (&runtime, b"tok")]);
864
865 DevBackend::export_excluding(&src, &dest, &[&cred]).unwrap();
866
867 let dest_keys = persisted_keys(&dest);
870 assert!(
871 !dest_keys.contains(&cred.to_string()),
872 "excluded credential must not persist in the staged copy"
873 );
874 assert!(
875 dest_keys.contains(&runtime.to_string()),
876 "runtime secret must survive the export"
877 );
878
879 let src_keys = persisted_keys(&src);
881 assert!(
882 src_keys.contains(&cred.to_string()) && src_keys.contains(&runtime.to_string()),
883 "source store must be left intact"
884 );
885
886 let reopened = DevBackend::with_persistence(&dest).unwrap();
889 assert!(reopened.get(&cred, None).unwrap().is_none());
890 assert!(reopened.get(&runtime, None).unwrap().is_some());
891
892 fs::remove_dir_all(&temp).unwrap();
893 }
894
895 #[test]
896 fn export_excluding_removes_key_that_delete_would_leave_on_disk() {
897 let temp = unique_temp_dir("export-vs-delete");
900 let src = temp.join(".dev.secrets.env");
901 let dest = temp.join(".seed.secrets.env");
902 let scope = sample_scope();
903 let cred = sample_uri(&scope, "kv", "deployer-credential");
904
905 seed_store(&src, &[(&cred, b"SA-KEY")]);
906 let backend = DevBackend::with_persistence(&src).unwrap();
907 backend.delete(&cred).unwrap();
908 drop(backend);
909 assert!(
910 persisted_keys(&src).contains(&cred.to_string()),
911 "delete tombstones but leaves the key + ciphertext on disk"
912 );
913
914 fs::remove_file(&src).unwrap();
915 seed_store(&src, &[(&cred, b"SA-KEY")]);
916 DevBackend::export_excluding(&src, &dest, &[&cred]).unwrap();
917 assert!(
918 !persisted_keys(&dest).contains(&cred.to_string()),
919 "export drops the whole key — no residual ciphertext"
920 );
921
922 fs::remove_dir_all(&temp).unwrap();
923 }
924
925 #[test]
926 fn export_excluding_rejects_missing_src_and_pre_existing_dest() {
927 let temp = unique_temp_dir("export-guards");
928 let src = temp.join(".dev.secrets.env");
929 let dest = temp.join(".seed.secrets.env");
930 let scope = sample_scope();
931 let cred = sample_uri(&scope, "kv", "deployer-credential");
932
933 assert!(DevBackend::export_excluding(&src, &dest, &[&cred]).is_err());
935 assert!(!src.exists(), "a missing source must not be recreated");
936 assert!(!dest.exists());
937
938 seed_store(&src, &[(&cred, b"SA-KEY")]);
939
940 assert!(DevBackend::export_excluding(&src, &src, &[&cred]).is_err());
942 assert!(
943 persisted_keys(&src).contains(&cred.to_string()),
944 "source must be untouched when dest resolves to it"
945 );
946
947 fs::write(&dest, b"pre-existing\n").unwrap();
950 assert!(DevBackend::export_excluding(&src, &dest, &[&cred]).is_err());
951 assert_eq!(
952 fs::read(&dest).unwrap(),
953 b"pre-existing\n",
954 "a pre-existing dest must be left untouched, not overwritten"
955 );
956
957 fs::remove_dir_all(&temp).unwrap();
958 }
959
960 #[test]
961 fn export_excluding_normalizes_versioned_exclusions() {
962 let temp = unique_temp_dir("export-version");
965 let src = temp.join(".dev.secrets.env");
966 let dest = temp.join(".seed.secrets.env");
967 let scope = sample_scope();
968 let cred = sample_uri(&scope, "kv", "deployer-credential");
969 seed_store(&src, &[(&cred, b"SA-KEY")]);
970
971 let versioned = cred.clone().with_version(Some("1")).unwrap();
972 DevBackend::export_excluding(&src, &dest, &[&versioned]).unwrap();
973 assert!(
974 !persisted_keys(&dest).contains(&cred.to_string()),
975 "a version-qualified exclusion must strip the versionless stored key"
976 );
977
978 fs::remove_dir_all(&temp).unwrap();
979 }
980
981 #[test]
982 fn export_excluding_strips_a_version_qualified_stored_key() {
983 let temp = unique_temp_dir("export-stored-version");
986 let src = temp.join(".dev.secrets.env");
987 let dest = temp.join(".seed.secrets.env");
988 let scope = sample_scope();
989 let cred = sample_uri(&scope, "kv", "deployer-credential");
990 let versioned = cred.clone().with_version(Some("1")).unwrap();
991 seed_store(&src, &[(&versioned, b"SA-KEY")]);
992 assert!(
993 persisted_keys(&src).iter().any(|key| key.ends_with("@1")),
994 "the stored key is version-qualified"
995 );
996
997 DevBackend::export_excluding(&src, &dest, &[&cred]).unwrap();
998 assert!(
999 persisted_keys(&dest)
1000 .iter()
1001 .all(|key| !key.contains("deployer-credential")),
1002 "a versionless exclusion must strip a version-qualified stored key"
1003 );
1004
1005 fs::remove_dir_all(&temp).unwrap();
1006 }
1007
1008 #[test]
1009 fn export_excluding_with_no_exclusions_carries_all_entries() {
1010 let temp = unique_temp_dir("export-empty");
1011 let src = temp.join(".dev.secrets.env");
1012 let dest = temp.join(".seed.secrets.env");
1013 let scope = sample_scope();
1014 let a = sample_uri(&scope, "kv", "alpha");
1015 let b = sample_uri(&scope, "kv", "beta");
1016 seed_store(&src, &[(&a, b"1"), (&b, b"2")]);
1017
1018 DevBackend::export_excluding(&src, &dest, &[]).unwrap();
1019 let keys = persisted_keys(&dest);
1020 assert!(keys.contains(&a.to_string()) && keys.contains(&b.to_string()));
1021
1022 fs::remove_dir_all(&temp).unwrap();
1023 }
1024}