Skip to main content

secrets_provider_dev/
lib.rs

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/// Simple development key provider that uses deterministic material to wrap DEKs.
24#[derive(Clone, Default)]
25pub struct DevKeyProvider {
26    master_key: [u8; 32],
27}
28
29impl DevKeyProvider {
30    /// Construct the provider from environment configuration.
31    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    /// Construct the provider by hashing arbitrary input into a fixed-size key.
37    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
65/// Whether two paths refer to the same store — lexically equal, or resolving to
66/// the same file (catches a symlinked destination). Used to refuse an export
67/// that would rewrite its own source.
68fn 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
75/// The versionless canonical form of a stored key, or `None` if it is not a
76/// parseable secret URI. Backend keys are `SecretUri::to_string()` outputs, so
77/// this normally round-trips; `None` only for a corrupt store line.
78fn 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
86/// The versionless canonical form of each exclusion URI.
87fn 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    /// Parse the persisted state out of an already-open, already-locked file.
151    /// Shared by [`load`](Self::load) (exclusive lock) and
152    /// [`snapshot`](Self::snapshot) (shared lock).
153    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    /// Read a consistent snapshot of the persisted state WITHOUT creating or
194    /// modifying the file. Opens read-only (so a read-only store still works)
195    /// and holds a shared lock during the read; the shared lock conflicts with
196    /// [`persist`](Self::persist)'s exclusive lock, so a concurrent writer
197    /// cannot yield a torn snapshot. A missing file is an error, never a
198    /// silently-recreated empty state.
199    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/// Development backend that stores ciphertexts in-memory with optional .env persistence.
283#[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    /// Construct a purely in-memory backend.
297    pub fn new() -> Self {
298        Self {
299            state: Arc::new(RwLock::new(State::default())),
300            persistence: None,
301        }
302    }
303
304    /// Construct a backend that persists state to the specified .env file.
305    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    /// Construct from environment configuration. If the configured file does not exist,
315    /// the backend falls back to in-memory storage.
316    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    /// Write a sanitized copy of the dev-store persisted at `src` to `dest`,
330    /// hard-excluding every entry whose URI is in `exclude`.
331    ///
332    /// Excluded URIs leave **no** residual ciphertext in `dest`: the whole key is
333    /// dropped from the snapshot before it is ever written, unlike the
334    /// tombstoning [`SecretsBackend::delete`] (which leaves the prior live
335    /// version's encrypted `record` on disk). Intended for stripping
336    /// control-plane material — e.g. a bound deployer credential — before a store
337    /// is staged into an untrusted runtime seed.
338    ///
339    /// The operation is transactional and lock-safe:
340    /// - it reads a consistent snapshot of `src` read-only under a shared lock
341    ///   (which conflicts with the writer's exclusive lock), so a concurrent
342    ///   writer cannot yield a torn or empty result and a `src` removed between
343    ///   calls fails loudly instead of being recreated empty;
344    /// - it never opens `src` for writing;
345    /// - it builds the sanitized store in a private temp file in `dest`'s
346    ///   directory and publishes it with a **no-clobber** rename, so `dest` never
347    ///   transiently holds an excluded entry and any failure leaves `dest`
348    ///   untouched.
349    ///
350    /// # Limitation
351    ///
352    /// The alias and no-clobber guards resolve pathnames, so the source-integrity
353    /// and no-clobber guarantees assume the **directories on the `src` and `dest`
354    /// paths are not concurrently manipulated by another actor** (e.g. a symlink
355    /// in `dest`'s parent repointed between the snapshot and the publish). Callers
356    /// must stage to a directory they control — a private temp dir is ideal.
357    /// Hardening against a hostile dest-parent (openat/`O_NOFOLLOW`
358    /// directory-handle operations) is out of scope for this local-store staging
359    /// primitive.
360    ///
361    /// `src` must exist and `dest` must be a **fresh** path that does not yet
362    /// exist and does not resolve to `src` — a pre-existing `dest` (including
363    /// `src`, a symlink to it, or a file a live backend still holds open) is
364    /// rejected rather than replaced, since replacing it could let a stale
365    /// in-memory snapshot resurrect an excluded record at `dest`.
366    pub fn export_excluding(src: &Path, dest: &Path, exclude: &[&SecretUri]) -> Result<()> {
367        // Reject a destination that is (or resolves to) the source up front. This
368        // is deterministic (no publish-time TOCTOU): were `src == dest`, a
369        // concurrent unlink of `src` after the snapshot could otherwise let the
370        // no-clobber publish succeed against the vanished pathname and recreate
371        // the operator's store with excluded records removed.
372        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        // Consistent read-only snapshot under a shared lock; src is never opened
379        // for writing or created.
380        let mut state = Persistence::snapshot(src)?;
381
382        // Compare on canonical (versionless) identity on BOTH sides. DevStore
383        // accepts version-qualified URIs, so a store can hold `…/name@1` and an
384        // exclusion can be `…/name` (or `…/name@2`); every version of the
385        // underlying secret must be stripped. Backend versions also live under a
386        // single versionless key, so this strips the whole `Vec<VersionEntry>`.
387        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                // An unparseable stored key cannot equal a well-formed excluded
393                // URI, so it is never the excluded secret — keep it rather than
394                // risk dropping an unrelated runtime entry.
395                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        // Publish atomically to a fresh dest: write a private temp file in dest's
403        // directory, fsync, then rename with no-clobber. The excluded entries are
404        // never written, so there is nothing to erase after the fact.
405        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    /// Decode the persisted `SECRETS_BACKEND_STATE=` blob and return the URI keys
815    /// it actually holds on disk — so a test can assert on residual ciphertext,
816    /// not just on what the API returns.
817    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        // dest: the credential key is gone entirely (no record, no ciphertext);
868        // the runtime secret is carried over.
869        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        // src: never modified — still resolves both entries.
880        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        // A fresh reader (the workload's view of the staged file) cannot recover
887        // the credential, but still resolves the runtime secret.
888        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        // Negative control: delete() only tombstones (the key + ciphertext stay
898        // on disk — the H3 leak); export drops the whole key.
899        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        // Missing src → error (no silent recreate), and no dest is created.
934        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        // dest == src → rejected (no-clobber), src untouched.
941        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        // A pre-existing, unrelated dest → rejected rather than replaced (a live
948        // backend could still hold its stale state and later resurrect a record).
949        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        // A stored key is versionless; excluding it with an `@version` suffix
963        // must still strip the whole (versioned + versionless) entry.
964        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        // The store itself holds a version-qualified key `…@1`; a versionless
984        // exclusion must still strip it (canonical-identity comparison).
985        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}