Skip to main content

orchestrator_security/
secret_store_crypto.rs

1use orchestrator_config::resource_store::SYSTEM_PROJECT;
2use aes_gcm_siv::Aes256GcmSiv;
3use aes_gcm_siv::aead::{Aead, KeyInit, Payload};
4use anyhow::{Context, Result, anyhow, bail};
5use base64::Engine;
6use rand::RngCore;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10use std::fs::OpenOptions;
11use std::io::Write;
12use std::path::{Path, PathBuf};
13
14const KEY_RELATIVE_PATH: &str = "secrets/secretstore.key";
15const KEY_META_RELATIVE_PATH: &str = "secrets/secretstore.key.meta.json";
16const KEY_ID_PRIMARY: &str = "primary";
17const KEY_SIZE_BYTES: usize = 32;
18const NONCE_SIZE_BYTES: usize = 12;
19/// SecretStore envelope scheme label persisted in encrypted specs.
20pub const SECRETSTORE_ENCRYPTION_SCHEME: &str = "secretstore.aead.v1";
21/// Redaction placeholder written when secret values must be hidden.
22pub const ENCRYPTED_PLACEHOLDER: &str = "[ENCRYPTED]";
23
24#[derive(Debug, Clone)]
25/// In-memory handle for one loaded SecretStore encryption key.
26pub struct SecretKeyHandle {
27    key_bytes: [u8; KEY_SIZE_BYTES],
28    key_id: String,
29    fingerprint: String,
30    #[allow(dead_code)]
31    path: PathBuf,
32}
33
34impl SecretKeyHandle {
35    /// Returns the stable identifier associated with this key handle.
36    pub fn key_id(&self) -> &str {
37        &self.key_id
38    }
39
40    /// Returns the stable fingerprint derived from the key material.
41    pub fn fingerprint(&self) -> &str {
42        &self.fingerprint
43    }
44
45    fn key_bytes(&self) -> &[u8; KEY_SIZE_BYTES] {
46        &self.key_bytes
47    }
48}
49
50#[derive(Debug, Clone)]
51/// Encrypts and decrypts SecretStore specs using one active key plus optional decrypt-only keys.
52pub struct SecretEncryption {
53    key: SecretKeyHandle,
54    /// Additional keys available for decryption (key_id → handle).
55    /// When constructed via `from_key`, this is empty (single-key mode).
56    decrypt_keys: std::collections::HashMap<String, SecretKeyHandle>,
57}
58
59impl SecretEncryption {
60    /// Creates a single-key encryptor and decryptor from one key handle.
61    pub fn from_key(key: SecretKeyHandle) -> Self {
62        Self {
63            key,
64            decrypt_keys: std::collections::HashMap::new(),
65        }
66    }
67
68    /// Build from a KeyRing — active key for encryption, all non-terminal keys for decryption.
69    pub fn from_keyring(keyring: &crate::secret_key_lifecycle::KeyRing) -> Result<Self> {
70        let active = keyring.active_key()?.clone();
71        let mut decrypt_keys = std::collections::HashMap::new();
72        for (kid, handle) in keyring.decrypt_keys_iter() {
73            decrypt_keys.insert(kid.to_string(), handle.clone());
74        }
75        Ok(Self {
76            key: active,
77            decrypt_keys,
78        })
79    }
80
81    /// Encrypts a SecretStore spec into an authenticated envelope bound to resource identity.
82    pub fn encrypt_secret_store_spec(
83        &self,
84        project: &str,
85        name: &str,
86        spec: &Value,
87    ) -> Result<String> {
88        let plain = serde_json::to_vec(spec).context("failed to serialize secret store spec")?;
89        let aad = SecretEnvelopeAad {
90            kind: "SecretStore".to_string(),
91            project: project.to_string(),
92            name: name.to_string(),
93        };
94        let cipher = Aes256GcmSiv::new_from_slice(self.key.key_bytes())
95            .map_err(|_| anyhow!("failed to initialize secret store cipher"))?;
96        let mut nonce_bytes = [0_u8; NONCE_SIZE_BYTES];
97        rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
98        let nonce = aes_gcm_siv::Nonce::from_slice(&nonce_bytes);
99        let aad_json = serde_json::to_vec(&aad).context("failed to serialize secret AAD")?;
100        let ciphertext = cipher
101            .encrypt(
102                nonce,
103                Payload {
104                    msg: &plain,
105                    aad: &aad_json,
106                },
107            )
108            .map_err(|_| anyhow!("failed to encrypt secret store spec"))?;
109        let envelope = SecretEnvelope {
110            encrypted: true,
111            scheme: SECRETSTORE_ENCRYPTION_SCHEME.to_string(),
112            key_id: self.key.key_id().to_string(),
113            nonce: base64::engine::general_purpose::STANDARD.encode(nonce_bytes),
114            ciphertext: base64::engine::general_purpose::STANDARD.encode(ciphertext),
115            aad,
116        };
117        serde_json::to_string(&envelope).context("failed to serialize encrypted secret envelope")
118    }
119
120    /// Decrypts a SecretStore envelope and verifies its authenticated resource identity.
121    pub fn decrypt_secret_store_spec(
122        &self,
123        project: &str,
124        name: &str,
125        spec_json: &str,
126    ) -> Result<Value> {
127        let envelope: SecretEnvelope =
128            serde_json::from_str(spec_json).context("failed to parse encrypted secret envelope")?;
129        if !envelope.encrypted {
130            bail!("secret store envelope missing encrypted marker");
131        }
132        if envelope.scheme != SECRETSTORE_ENCRYPTION_SCHEME {
133            bail!(
134                "unsupported secret store encryption scheme: {}",
135                envelope.scheme
136            );
137        }
138        if envelope.aad.kind != "SecretStore"
139            || envelope.aad.project != project
140            || envelope.aad.name != name
141        {
142            bail!(
143                "secret store envelope AAD mismatch for SecretStore/{}/{}",
144                project,
145                name
146            );
147        }
148
149        // Multi-key dispatch: look up the key matching envelope.key_id
150        let decrypt_handle = self.resolve_decrypt_key(&envelope.key_id)?;
151
152        let nonce_bytes = base64::engine::general_purpose::STANDARD
153            .decode(&envelope.nonce)
154            .context("failed to decode secret envelope nonce")?;
155        if nonce_bytes.len() != NONCE_SIZE_BYTES {
156            bail!("invalid secret envelope nonce length");
157        }
158        let ciphertext = base64::engine::general_purpose::STANDARD
159            .decode(&envelope.ciphertext)
160            .context("failed to decode secret envelope ciphertext")?;
161        let cipher = Aes256GcmSiv::new_from_slice(decrypt_handle.key_bytes())
162            .map_err(|_| anyhow!("failed to initialize secret store cipher"))?;
163        let aad_json =
164            serde_json::to_vec(&envelope.aad).context("failed to serialize envelope AAD")?;
165        let plain = cipher
166            .decrypt(
167                aes_gcm_siv::Nonce::from_slice(&nonce_bytes),
168                Payload {
169                    msg: &ciphertext,
170                    aad: &aad_json,
171                },
172            )
173            .map_err(|_| {
174                anyhow!(
175                    "failed to decrypt secret store spec (key_id: {})",
176                    envelope.key_id
177                )
178            })?;
179        serde_json::from_slice(&plain).context("failed to parse decrypted secret store spec")
180    }
181
182    /// Resolve which key handle to use for decryption.
183    /// Checks decrypt_keys map first, then falls back to the primary key.
184    fn resolve_decrypt_key(&self, key_id: &str) -> Result<&SecretKeyHandle> {
185        if let Some(handle) = self.decrypt_keys.get(key_id) {
186            return Ok(handle);
187        }
188        if self.key.key_id() == key_id {
189            return Ok(&self.key);
190        }
191        bail!(
192            "no decryption key available for key_id '{}'; available keys: [{}]",
193            key_id,
194            {
195                let mut ids: Vec<&str> = self.decrypt_keys.keys().map(|s| s.as_str()).collect();
196                ids.push(self.key.key_id());
197                ids.join(", ")
198            }
199        )
200    }
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
204struct SecretKeyMetadata {
205    key_id: String,
206    created_at: String,
207    last_rotated_at: String,
208    fingerprint: String,
209    format_version: u32,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213struct SecretEnvelopeAad {
214    kind: String,
215    project: String,
216    name: String,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220struct SecretEnvelope {
221    #[serde(rename = "_encrypted")]
222    encrypted: bool,
223    scheme: String,
224    key_id: String,
225    nonce: String,
226    ciphertext: String,
227    aad: SecretEnvelopeAad,
228}
229
230/// Load a key file and return a SecretKeyHandle with the given key_id.
231pub fn load_key_file_as_handle(path: &Path, key_id: &str) -> Result<SecretKeyHandle> {
232    validate_secret_key_permissions(path)?;
233    let encoded = std::fs::read_to_string(path)
234        .with_context(|| format!("failed to read key file {}", path.display()))?;
235    let decoded =
236        base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded.trim())
237            .context("failed to decode key file")?;
238    if decoded.len() != KEY_SIZE_BYTES {
239        bail!(
240            "invalid key length in {}: expected {} bytes",
241            path.display(),
242            KEY_SIZE_BYTES
243        );
244    }
245    let mut key_bytes = [0_u8; KEY_SIZE_BYTES];
246    key_bytes.copy_from_slice(&decoded);
247    let fingerprint = key_fingerprint(&key_bytes);
248    Ok(SecretKeyHandle {
249        key_bytes,
250        key_id: key_id.to_string(),
251        fingerprint,
252        path: path.to_path_buf(),
253    })
254}
255
256/// Generate a new random key and write it to the given path with proper permissions.
257/// Returns the SecretKeyHandle for the new key.
258pub fn generate_and_write_key_file(path: &Path, key_id: &str) -> Result<SecretKeyHandle> {
259    let mut key_bytes = [0_u8; KEY_SIZE_BYTES];
260    rand::rngs::OsRng.fill_bytes(&mut key_bytes);
261    let encoded = base64::engine::general_purpose::STANDARD.encode(key_bytes);
262    write_atomic_secret_file(path, encoded.as_bytes())?;
263    let fingerprint = key_fingerprint(&key_bytes);
264    Ok(SecretKeyHandle {
265        key_bytes,
266        key_id: key_id.to_string(),
267        fingerprint,
268        path: path.to_path_buf(),
269    })
270}
271
272/// Resolves the canonical path of the primary SecretStore key file.
273pub fn secret_key_path(data_dir: &Path) -> PathBuf {
274    data_dir.join(KEY_RELATIVE_PATH)
275}
276
277/// Resolves the path of the metadata file associated with the primary SecretStore key.
278pub fn secret_key_meta_path(data_dir: &Path) -> PathBuf {
279    data_dir.join(KEY_META_RELATIVE_PATH)
280}
281
282/// Resolves the application root from a database path in either nested or flat layouts.
283pub fn resolve_data_dir_from_db_path(db_path: &Path) -> Result<PathBuf> {
284    let parent = db_path
285        .parent()
286        .with_context(|| format!("db path has no parent: {}", db_path.display()))?;
287    if parent.file_name().and_then(|s| s.to_str()) == Some("data") {
288        parent
289            .parent()
290            .map(Path::to_path_buf)
291            .with_context(|| format!("data dir has no parent: {}", parent.display()))
292    } else {
293        Ok(parent.to_path_buf())
294    }
295}
296
297/// Loads the existing primary key or initializes one when no encrypted SecretStore data exists.
298pub fn ensure_secret_key(data_dir: &Path, db_path: &Path) -> Result<SecretKeyHandle> {
299    if let Some(existing) = load_existing_secret_key(data_dir)? {
300        return Ok(existing);
301    }
302    if encrypted_secret_data_exists(db_path)? {
303        bail!(
304            "secret store key missing at {} while encrypted SecretStore data exists; restore the original key before starting",
305            secret_key_path(data_dir).display()
306        );
307    }
308    initialize_secret_key(data_dir)
309}
310
311/// Loads the primary SecretStore key if it already exists on disk.
312pub fn load_existing_secret_key(data_dir: &Path) -> Result<Option<SecretKeyHandle>> {
313    let path = secret_key_path(data_dir);
314    if !path.exists() {
315        return Ok(None);
316    }
317    validate_secret_key_permissions(&path)?;
318    let encoded = std::fs::read_to_string(&path)
319        .with_context(|| format!("failed to read secret key file {}", path.display()))?;
320    let decoded = base64::engine::general_purpose::STANDARD
321        .decode(encoded.trim())
322        .context("failed to decode secret key file")?;
323    if decoded.len() != KEY_SIZE_BYTES {
324        bail!(
325            "invalid secret key length: expected {} bytes",
326            KEY_SIZE_BYTES
327        );
328    }
329    let mut key_bytes = [0_u8; KEY_SIZE_BYTES];
330    key_bytes.copy_from_slice(&decoded);
331    let fingerprint = key_fingerprint(&key_bytes);
332    Ok(Some(SecretKeyHandle {
333        key_bytes,
334        key_id: KEY_ID_PRIMARY.to_string(),
335        fingerprint,
336        path,
337    }))
338}
339
340/// Returns `true` when the serialized spec looks like an encrypted SecretStore envelope.
341pub fn is_encrypted_secret_store_json(spec_json: &str) -> bool {
342    spec_json.contains("\"scheme\":\"secretstore.aead.v1\"")
343        || spec_json.contains("\"_encrypted\":true")
344}
345
346/// Replaces every secret value in a `data` map with the standard redaction marker.
347pub fn redact_secret_data_map(map: &mut serde_json::Map<String, Value>) {
348    for value in map.values_mut() {
349        *value = Value::String(ENCRYPTED_PLACEHOLDER.to_string());
350    }
351}
352
353fn initialize_secret_key(data_dir: &Path) -> Result<SecretKeyHandle> {
354    let key_path = secret_key_path(data_dir);
355    let meta_path = secret_key_meta_path(data_dir);
356    let secrets_dir = key_path
357        .parent()
358        .with_context(|| format!("secret key path has no parent: {}", key_path.display()))?;
359    std::fs::create_dir_all(secrets_dir)
360        .with_context(|| format!("failed to create secrets dir {}", secrets_dir.display()))?;
361    #[cfg(unix)]
362    {
363        use std::os::unix::fs::PermissionsExt;
364        std::fs::set_permissions(secrets_dir, std::fs::Permissions::from_mode(0o700))
365            .with_context(|| {
366                format!(
367                    "failed to set permissions on secrets dir {}",
368                    secrets_dir.display()
369                )
370            })?;
371    }
372    let mut key_bytes = [0_u8; KEY_SIZE_BYTES];
373    rand::rngs::OsRng.fill_bytes(&mut key_bytes);
374    let encoded = base64::engine::general_purpose::STANDARD.encode(key_bytes);
375    write_atomic_secret_file(&key_path, encoded.as_bytes())?;
376    let now = crate::now_ts();
377    let metadata = SecretKeyMetadata {
378        key_id: KEY_ID_PRIMARY.to_string(),
379        created_at: now.clone(),
380        last_rotated_at: now,
381        fingerprint: key_fingerprint(&key_bytes),
382        format_version: 1,
383    };
384    let meta_json =
385        serde_json::to_vec_pretty(&metadata).context("failed to serialize key metadata")?;
386    write_atomic_secret_file(&meta_path, &meta_json)?;
387    Ok(SecretKeyHandle {
388        key_bytes,
389        key_id: metadata.key_id,
390        fingerprint: metadata.fingerprint,
391        path: key_path,
392    })
393}
394
395fn write_atomic_secret_file(path: &Path, contents: &[u8]) -> Result<()> {
396    let tmp_path = path.with_extension(format!(
397        "{}tmp",
398        path.extension()
399            .and_then(|ext| ext.to_str())
400            .map(|ext| format!("{ext}."))
401            .unwrap_or_default()
402    ));
403    let mut file = OpenOptions::new()
404        .write(true)
405        .create_new(true)
406        .open(&tmp_path)
407        .with_context(|| {
408            format!(
409                "failed to create temporary secret file {}",
410                tmp_path.display()
411            )
412        })?;
413    #[cfg(unix)]
414    {
415        use std::os::unix::fs::PermissionsExt;
416        file.set_permissions(std::fs::Permissions::from_mode(0o600))
417            .with_context(|| {
418                format!(
419                    "failed to set permissions on temporary secret file {}",
420                    tmp_path.display()
421                )
422            })?;
423    }
424    file.write_all(contents).with_context(|| {
425        format!(
426            "failed to write temporary secret file {}",
427            tmp_path.display()
428        )
429    })?;
430    file.sync_all().with_context(|| {
431        format!(
432            "failed to fsync temporary secret file {}",
433            tmp_path.display()
434        )
435    })?;
436    drop(file);
437    std::fs::rename(&tmp_path, path).with_context(|| {
438        format!(
439            "failed to rename temporary secret file {} -> {}",
440            tmp_path.display(),
441            path.display()
442        )
443    })?;
444    validate_secret_key_permissions(path)?;
445    Ok(())
446}
447
448fn encrypted_secret_data_exists(db_path: &Path) -> Result<bool> {
449    let conn = crate::open_conn(db_path)?;
450    let resources_exists: bool = conn
451        .query_row(
452            "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='resources'",
453            [],
454            |row| row.get(0),
455        )
456        .unwrap_or(false);
457    let versions_exists: bool = conn
458        .query_row(
459            "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='resource_versions'",
460            [],
461            |row| row.get(0),
462        )
463        .unwrap_or(false);
464    let mut encrypted = false;
465    if resources_exists {
466        encrypted = conn.query_row(
467            "SELECT EXISTS(
468                SELECT 1 FROM resources
469                WHERE kind = 'SecretStore'
470                  AND (instr(spec_json, '\"scheme\":\"secretstore.aead.v1\"') > 0
471                       OR instr(spec_json, '\"_encrypted\":true') > 0)
472            )",
473            [],
474            |row| row.get(0),
475        )?;
476    }
477    if !encrypted && versions_exists {
478        encrypted = conn.query_row(
479            "SELECT EXISTS(
480                SELECT 1 FROM resource_versions
481                WHERE kind = 'SecretStore'
482                  AND version > 0
483                  AND (instr(spec_json, '\"scheme\":\"secretstore.aead.v1\"') > 0
484                       OR instr(spec_json, '\"_encrypted\":true') > 0)
485            )",
486            [],
487            |row| row.get(0),
488        )?;
489    }
490    Ok(encrypted)
491}
492
493fn validate_secret_key_permissions(path: &Path) -> Result<()> {
494    #[cfg(unix)]
495    {
496        use std::os::unix::fs::PermissionsExt;
497        let metadata = std::fs::metadata(path)
498            .with_context(|| format!("failed to read secret key metadata {}", path.display()))?;
499        let mode = metadata.permissions().mode() & 0o777;
500        if mode & 0o077 != 0 {
501            bail!(
502                "secret key file {} must have permissions 0600 or stricter (found {:o})",
503                path.display(),
504                mode
505            );
506        }
507    }
508    Ok(())
509}
510
511fn key_fingerprint(key_bytes: &[u8; KEY_SIZE_BYTES]) -> String {
512    let digest = Sha256::digest(key_bytes);
513    digest[..8]
514        .iter()
515        .map(|byte| format!("{byte:02x}"))
516        .collect()
517}
518
519/// Decodes a stored resource spec, decrypting SecretStore payloads when needed.
520pub fn decrypt_resource_spec_json(
521    encryption: Option<&SecretEncryption>,
522    kind: &str,
523    project: &str,
524    name: &str,
525    spec_json: &str,
526) -> Result<Value> {
527    if kind != "SecretStore" {
528        return serde_json::from_str(spec_json).context("failed to parse resource spec json");
529    }
530    if !is_encrypted_secret_store_json(spec_json) {
531        return serde_json::from_str(spec_json)
532            .context("failed to parse plaintext secret store spec json");
533    }
534    let encryption = encryption.ok_or_else(|| {
535        anyhow!(
536            "encrypted SecretStore/{}/{} cannot be loaded because the secret key is unavailable",
537            project,
538            name
539        )
540    })?;
541    encryption.decrypt_secret_store_spec(project, name, spec_json)
542}
543
544/// Encodes a resource spec, encrypting SecretStore payloads and plain-serializing others.
545pub fn encrypt_resource_spec_json(
546    encryption: &SecretEncryption,
547    kind: &str,
548    project: &str,
549    name: &str,
550    spec: &Value,
551) -> Result<String> {
552    if kind == "SecretStore" {
553        encryption.encrypt_secret_store_spec(project, name, spec)
554    } else {
555        serde_json::to_string(spec).context("failed to serialize resource spec json")
556    }
557}
558
559/// Returns the provided project identifier or the system project when missing.
560pub fn secret_project_or_default(project: Option<&str>) -> &str {
561    project
562        .filter(|value| !value.trim().is_empty())
563        .unwrap_or(SYSTEM_PROJECT)
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use tempfile::tempdir;
570
571    #[test]
572    fn ensure_secret_key_creates_and_reuses_key_file() {
573        let temp = tempdir().expect("tempdir");
574        let db_path = temp.path().join("data/agent_orchestrator.db");
575        std::fs::create_dir_all(db_path.parent().expect("db path should have parent"))
576            .expect("create data dir");
577        crate::init_test_schema(&db_path).expect("init schema");
578
579        let first = ensure_secret_key(temp.path(), &db_path).expect("create key");
580        let second = ensure_secret_key(temp.path(), &db_path).expect("reuse key");
581
582        assert_eq!(first.fingerprint(), second.fingerprint());
583        assert!(secret_key_path(temp.path()).exists());
584        assert!(secret_key_meta_path(temp.path()).exists());
585    }
586
587    #[test]
588    fn encrypt_and_decrypt_secret_store_round_trip() {
589        let temp = tempdir().expect("tempdir");
590        let db_path = temp.path().join("agent_orchestrator.db");
591        crate::init_test_schema(&db_path).expect("init schema");
592        let key = ensure_secret_key(temp.path(), &db_path).expect("create key");
593        let encryption = SecretEncryption::from_key(key);
594        let spec = serde_json::json!({"data": {"API_KEY": "sk-123"}});
595
596        let cipher = encryption
597            .encrypt_secret_store_spec("default", "api-keys", &spec)
598            .expect("encrypt");
599        assert!(is_encrypted_secret_store_json(&cipher));
600        assert!(!cipher.contains("sk-123"));
601
602        let plain = encryption
603            .decrypt_secret_store_spec("default", "api-keys", &cipher)
604            .expect("decrypt");
605        assert_eq!(plain, spec);
606    }
607
608    #[test]
609    fn ensure_secret_key_refuses_to_regenerate_when_encrypted_data_exists() {
610        let temp = tempdir().expect("tempdir");
611        let db_path = temp.path().join("agent_orchestrator.db");
612        crate::init_test_schema(&db_path).expect("init schema");
613        let key = ensure_secret_key(temp.path(), &db_path).expect("create key");
614        let encryption = SecretEncryption::from_key(key);
615        let spec = serde_json::json!({"data": {"API_KEY": "sk-123"}});
616        let cipher = encryption
617            .encrypt_secret_store_spec("default", "api-keys", &spec)
618            .expect("encrypt");
619        let conn = crate::open_conn(&db_path).expect("open sqlite");
620        conn.execute(
621            "INSERT INTO resources (kind, project, name, api_version, spec_json, metadata_json, generation, created_at, updated_at)
622             VALUES ('SecretStore', 'default', 'api-keys', 'orchestrator.dev/v2', ?1, '{}', 1, datetime('now'), datetime('now'))",
623            rusqlite::params![cipher],
624        )
625        .expect("insert encrypted secret resource");
626        std::fs::remove_file(secret_key_path(temp.path())).expect("remove secret key");
627
628        let err =
629            ensure_secret_key(temp.path(), &db_path).expect_err("should refuse to regenerate");
630        assert!(
631            err.to_string()
632                .contains("encrypted SecretStore data exists")
633        );
634    }
635
636    #[test]
637    fn resolve_data_dir_from_db_path_accepts_data_and_flat_layouts() {
638        let temp = tempdir().expect("tempdir");
639        let nested = temp.path().join("data/agent_orchestrator.db");
640        let flat = temp.path().join("agent_orchestrator.db");
641
642        assert_eq!(
643            resolve_data_dir_from_db_path(&nested).expect("nested root"),
644            temp.path()
645        );
646        assert_eq!(
647            resolve_data_dir_from_db_path(&flat).expect("flat root"),
648            temp.path()
649        );
650    }
651}