Skip to main content

vault_core/
cxf.rs

1//! FIDO Credential Exchange (CXF) import and export — Phase 24.5.
2//!
3//! CXF (FIDO Alliance) is the JSON format password managers are converging on
4//! for moving credentials between products. This is the one place it is read
5//! or written: `unv-cli` calls it directly, and the desktop app reaches it
6//! over IPC, the same split TOTP import/export already uses and for the same
7//! reason — six formats parsed twice is six chances for the app and the CLI
8//! to disagree about what a file meant.
9//!
10//! # What this is modelled from, and what that means
11//!
12//! Built from the public CXF field tables (`Item`, `Collection`, the
13//! `basic-auth` / `api-key` / `totp` / `note` / `wifi` / `ssh-key` /
14//! `custom-fields` credential shapes) rather than against a corpus of real
15//! exports from other managers — the design's own note said to check which
16//! managers emit a CXF **file** today before building this, and that check is
17//! still outstanding. Treat the shape here as a reasonable-effort reading of
18//! the spec, not a verified interop guarantee, until it has been run against
19//! a real export from at least one other product.
20//!
21//! # The mapping
22//!
23//! An `Item` with **one** credential becomes a plain entry. An `Item` with
24//! **several** becomes a Phase 24.1 bundle: one `secretType: "bundle"` parent
25//! plus one member per non-`custom-fields` credential. CXF custom fields become
26//! the bundle's local variables, so they can participate in scoped templates
27//! rather than appearing as a fake member.
28//!
29//! A `totp` credential never becomes its own entry: it is Phase 22's stored
30//! seed, a field *on* whichever entry the rest of the item produced, matching
31//! how this project already refuses to give TOTP its own `SecretType`.
32//!
33//! Every UnENVerse type without a native CXF shape — the majority of the 26 in
34//! `secret_types` — exports as `custom-fields` carrying an
35//! `_unenverse_type` field, so another manager sees labelled fields and
36//! UnENVerse-to-UnENVerse round-trips losslessly. Import of `custom-fields`
37//! reads `_unenverse_type` back when present and falls back to `extra_vars`
38//! otherwise, so a CXF file honestly written by some other tool still imports
39//! as something rather than being refused.
40
41use serde::{Deserialize, Serialize};
42use serde_json::{json, Value};
43
44// ── The CXF document shape ─────────────────────────────────────────────────
45
46#[derive(Serialize, Deserialize, Default)]
47pub struct CxfDocument {
48    #[serde(default)]
49    pub items: Vec<CxfItem>,
50}
51
52#[derive(Serialize, Deserialize, Default)]
53pub struct CxfItem {
54    pub id: String,
55    pub title: String,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub subtitle: Option<String>,
58    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
59    pub favorite: bool,
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub tags: Vec<String>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub scope: Option<CxfScope>,
64    #[serde(default)]
65    pub credentials: Vec<CxfCredential>,
66}
67
68#[derive(Serialize, Deserialize, Default)]
69pub struct CxfScope {
70    #[serde(default, skip_serializing_if = "Vec::is_empty")]
71    pub urls: Vec<String>,
72}
73
74#[derive(Serialize, Deserialize)]
75#[serde(tag = "type", rename_all = "kebab-case")]
76pub enum CxfCredential {
77    BasicAuth {
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        username: Option<String>,
80        password: String,
81    },
82    ApiKey {
83        key: String,
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        username: Option<String>,
86        #[serde(default, skip_serializing_if = "Option::is_none")]
87        key_type: Option<String>,
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        url: Option<String>,
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        expiry_date: Option<String>,
92    },
93    Totp {
94        secret: String,
95        #[serde(default, skip_serializing_if = "Option::is_none")]
96        issuer: Option<String>,
97        #[serde(default, skip_serializing_if = "Option::is_none")]
98        username: Option<String>,
99        #[serde(default, skip_serializing_if = "Option::is_none")]
100        period: Option<u64>,
101        #[serde(default, skip_serializing_if = "Option::is_none")]
102        digits: Option<u32>,
103        #[serde(default, skip_serializing_if = "Option::is_none")]
104        algorithm: Option<String>,
105    },
106    Note {
107        content: String,
108    },
109    Wifi {
110        ssid: String,
111        #[serde(default, skip_serializing_if = "Option::is_none")]
112        network_security_type: Option<String>,
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        passphrase: Option<String>,
115        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
116        hidden: bool,
117    },
118    SshKey {
119        private_key: String,
120        #[serde(default, skip_serializing_if = "Option::is_none")]
121        public_key: Option<String>,
122    },
123    #[serde(rename = "custom-fields")]
124    CustomFields {
125        fields: Vec<CxfField>,
126    },
127}
128
129#[derive(Serialize, Deserialize)]
130pub struct CxfField {
131    pub name: String,
132    pub value: String,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub field_type: Option<String>,
135}
136
137// ── Export: our entries → a CXF document ───────────────────────────────────
138
139fn s(entry: &Value, key: &str) -> Option<String> {
140    entry
141        .get(key)
142        .and_then(|v| v.as_str())
143        .filter(|v| !v.is_empty())
144        .map(str::to_string)
145}
146
147fn has_totp(entry: &Value) -> bool {
148    s(entry, "totp_secret").is_some()
149}
150
151fn totp_credential(entry: &Value) -> CxfCredential {
152    CxfCredential::Totp {
153        secret: s(entry, "totp_secret").unwrap_or_default(),
154        issuer: s(entry, "provider"),
155        username: s(entry, "account_name"),
156        period: entry.get("totp_period").and_then(|v| v.as_u64()),
157        digits: entry
158            .get("totp_digits")
159            .and_then(|v| v.as_u64())
160            .map(|d| d as u32),
161        algorithm: s(entry, "totp_algorithm"),
162    }
163}
164
165/// Every field CXF has no native slot for, as `custom-fields`, tagged with the
166/// UnENVerse type so a re-import (from this program or another UnENVerse
167/// instance) recovers it exactly. This is the fallback every type without a
168/// native mapping below uses, and it is what keeps the export lossless.
169fn custom_fields_credential(entry: &Value) -> CxfCredential {
170    let mut fields = Vec::new();
171    let secret_type = s(entry, "secretType").unwrap_or_else(|| "api_key".to_string());
172    fields.push(CxfField {
173        name: "_unenverse_type".to_string(),
174        value: secret_type,
175        field_type: None,
176    });
177    for key in [
178        "api_key",
179        "api_secret",
180        "api_url",
181        "key_id",
182        "user_agent",
183        "mount_path",
184        "composite_template",
185        "connection_string_note",
186    ] {
187        if let Some(v) = s(entry, key) {
188            fields.push(CxfField {
189                name: key.to_string(),
190                value: v,
191                field_type: None,
192            });
193        }
194    }
195    if let Some(vars) = entry.get("extra_vars").and_then(|v| v.as_array()) {
196        for var in vars {
197            let (Some(key), Some(value)) = (
198                var.get("key").and_then(|v| v.as_str()),
199                var.get("value").and_then(|v| v.as_str()),
200            ) else {
201                continue;
202            };
203            fields.push(CxfField {
204                name: key.to_string(),
205                value: value.to_string(),
206                field_type: None,
207            });
208        }
209    }
210    CxfCredential::CustomFields { fields }
211}
212
213/// CXF has no bundle-level fields; this extension preserves parent locals.
214const BUNDLE_LOCAL_ESCAPE: &str = "__unenverse_local__";
215
216fn encode_bundle_local_name(name: &str) -> String {
217    if name == "_unenverse_type"
218        || name == "_unenverse_bundle"
219        || name.starts_with(BUNDLE_LOCAL_ESCAPE)
220    {
221        format!("{BUNDLE_LOCAL_ESCAPE}{name}")
222    } else {
223        name.to_string()
224    }
225}
226
227fn decode_bundle_local_name(name: &str) -> &str {
228    name.strip_prefix(BUNDLE_LOCAL_ESCAPE).unwrap_or(name)
229}
230
231fn bundle_locals_credential(entry: Option<&Value>) -> CxfCredential {
232    let mut fields = vec![CxfField {
233        name: "_unenverse_bundle".into(),
234        value: "true".into(),
235        field_type: None,
236    }];
237    if let Some(vars) = entry
238        .and_then(|value| value.get("extra_vars"))
239        .and_then(Value::as_array)
240    {
241        fields.extend(vars.iter().filter_map(|var| {
242            Some(CxfField {
243                name: encode_bundle_local_name(var.get("key")?.as_str()?),
244                value: var.get("value")?.as_str()?.to_string(),
245                field_type: None,
246            })
247        }));
248    }
249    CxfCredential::CustomFields { fields }
250}
251
252/// One entry's non-TOTP credential, native where a mapping exists in
253/// `secret_types.json`, `custom-fields` otherwise.
254fn native_credential(entry: &Value) -> CxfCredential {
255    match s(entry, "secretType").as_deref() {
256        Some("password") => CxfCredential::BasicAuth {
257            username: s(entry, "account_name"),
258            password: s(entry, "api_key").unwrap_or_default(),
259        },
260        Some("api_key") | Some("registry_token") | Some("local_service") => CxfCredential::ApiKey {
261            key: s(entry, "api_key").unwrap_or_default(),
262            username: s(entry, "account_name"),
263            key_type: s(entry, "secretType"),
264            url: s(entry, "api_url"),
265            expiry_date: s(entry, "expires_at"),
266        },
267        Some("secure_note") => CxfCredential::Note {
268            content: s(entry, "description")
269                .or_else(|| s(entry, "api_key"))
270                .unwrap_or_default(),
271        },
272        Some("ssh_key") => CxfCredential::SshKey {
273            private_key: s(entry, "api_key").unwrap_or_default(),
274            public_key: s(entry, "api_secret"),
275        },
276        Some("wifi") => CxfCredential::Wifi {
277            ssid: s(entry, "account_name")
278                .or_else(|| s(entry, "provider"))
279                .unwrap_or_default(),
280            network_security_type: extra_var(entry, "security"),
281            passphrase: s(entry, "api_key"),
282            hidden: extra_var(entry, "hidden").as_deref() == Some("true"),
283        },
284        _ => custom_fields_credential(entry),
285    }
286}
287
288fn extra_var(entry: &Value, key: &str) -> Option<String> {
289    entry
290        .get("extra_vars")
291        .and_then(|v| v.as_array())
292        .and_then(|a| {
293            a.iter()
294                .find(|var| var.get("key").and_then(|v| v.as_str()) == Some(key))
295        })
296        .and_then(|var| var.get("value").and_then(|v| v.as_str()))
297        .map(str::to_string)
298}
299
300fn item_from_entry(entry: &Value) -> CxfItem {
301    let mut credentials = vec![native_credential(entry)];
302    if has_totp(entry) {
303        credentials.push(totp_credential(entry));
304    }
305    let urls = s(entry, "api_url").into_iter().collect::<Vec<_>>();
306    CxfItem {
307        id: s(entry, "id").unwrap_or_default(),
308        title: s(entry, "provider").unwrap_or_default(),
309        subtitle: s(entry, "account_name"),
310        favorite: entry
311            .get("pinned")
312            .and_then(|v| v.as_bool())
313            .unwrap_or(false),
314        tags: entry
315            .get("tags")
316            .and_then(|v| v.as_array())
317            .map(|a| {
318                a.iter()
319                    .filter_map(|t| t.as_str().map(str::to_string))
320                    .collect()
321            })
322            .unwrap_or_default(),
323        scope: if urls.is_empty() {
324            None
325        } else {
326            Some(CxfScope { urls })
327        },
328        credentials,
329    }
330}
331
332/// Builds a CXF document from a slice of entries (`VaultData.api_keys`).
333///
334/// Bundle members (`bundle_id` set) are grouped into one multi-credential
335/// `Item` per bundle; the bundle's own parent entry contributes the item's
336/// title but no credential of its own — a `bundle` entry's payload is its
337/// members, not a value in `api_key`.
338pub fn export(entries: &[Value]) -> CxfDocument {
339    let mut bundles: std::collections::BTreeMap<String, Vec<&Value>> = Default::default();
340    let mut bundle_parents: std::collections::HashMap<String, &Value> = Default::default();
341    let mut standalone = Vec::new();
342
343    for e in entries {
344        if s(e, "secretType").as_deref() == Some("bundle") {
345            if let Some(id) = s(e, "id") {
346                bundle_parents.insert(id.clone(), e);
347                bundles.entry(id).or_default();
348            }
349            continue;
350        }
351        match s(e, "bundle_id") {
352            Some(bid) => bundles.entry(bid).or_default().push(e),
353            None => standalone.push(e),
354        }
355    }
356
357    let mut items: Vec<CxfItem> = standalone.iter().map(|e| item_from_entry(e)).collect();
358    for (bundle_id, members) in bundles {
359        let mut credentials = vec![bundle_locals_credential(
360            bundle_parents.get(&bundle_id).copied(),
361        )];
362        for m in &members {
363            credentials.push(native_credential(m));
364            if has_totp(m) {
365                credentials.push(totp_credential(m));
366            }
367        }
368        items.push(CxfItem {
369            id: bundle_id.clone(),
370            title: bundle_parents
371                .get(&bundle_id)
372                .and_then(|parent| s(parent, "provider"))
373                .unwrap_or_else(|| "Bundle".to_string()),
374            subtitle: None,
375            favorite: false,
376            tags: Vec::new(),
377            scope: None,
378            credentials,
379        });
380    }
381
382    CxfDocument { items }
383}
384
385// ── Import: a CXF document → our entries ───────────────────────────────────
386
387/// One imported entry, or two when an `Item` held several non-TOTP
388/// credentials (a bundle parent plus its members).
389fn entries_from_item(item: &CxfItem, mut new_id: impl FnMut() -> String, now: &str) -> Vec<Value> {
390    let non_totp: Vec<&CxfCredential> = item
391        .credentials
392        .iter()
393        .filter(|c| !matches!(c, CxfCredential::Totp { .. }))
394        .collect();
395    let totp: Option<&CxfCredential> = item
396        .credentials
397        .iter()
398        .find(|c| matches!(c, CxfCredential::Totp { .. }));
399    let is_bundle = non_totp.iter().any(|credential| match credential {
400        CxfCredential::CustomFields { fields } => fields
401            .iter()
402            .any(|field| field.name == "_unenverse_bundle" && field.value == "true"),
403        _ => false,
404    });
405
406    let apply_totp = |entry: &mut Value| {
407        if let Some(CxfCredential::Totp {
408            secret,
409            period,
410            digits,
411            algorithm,
412            ..
413        }) = totp
414        {
415            entry["totp_secret"] = json!(secret);
416            if let Some(p) = period {
417                entry["totp_period"] = json!(p);
418            }
419            if let Some(d) = digits {
420                entry["totp_digits"] = json!(d);
421            }
422            if let Some(a) = algorithm {
423                entry["totp_algorithm"] = json!(a);
424            }
425        }
426    };
427
428    if non_totp.is_empty() {
429        // A TOTP-only item (rare, but the format allows it): a seed with
430        // nowhere else to live, exactly what Phase 22's import produces for
431        // the same case — an entry with an empty primary.
432        let mut entry = base_entry(&new_id(), &item.title, "password", now);
433        apply_totp(&mut entry);
434        return vec![entry];
435    }
436
437    if non_totp.len() == 1 && !is_bundle {
438        let mut entry = entry_from_credential(&new_id(), &item.title, non_totp[0], now);
439        apply_totp(&mut entry);
440        return vec![entry];
441    }
442
443    // Several non-TOTP credentials: a bundle. The TOTP credential (if any)
444    // attaches to the first member — CXF gives no stronger signal for which
445    // login it belongs to, and the alternative is dropping it.
446    let bundle_id = new_id();
447    let mut members = Vec::new();
448    let mut local_vars = Vec::new();
449    for credential in non_totp {
450        match credential {
451            CxfCredential::CustomFields { fields } => local_vars.extend(
452                fields
453                    .iter()
454                    .filter(|field| {
455                        field.name != "_unenverse_type" && field.name != "_unenverse_bundle"
456                    })
457                    .map(|field| {
458                        json!({ "key": decode_bundle_local_name(&field.name), "value": field.value })
459                    }),
460            ),
461            _ => members.push(credential),
462        }
463    }
464    let mut out = vec![json!({
465        "id": bundle_id,
466        "provider": item.title,
467        "api_key": "",
468        "price_type": "free",
469        "secretType": "bundle",
470        "categories": [],
471        "scopes": [],
472        "projectIds": ["Universal"],
473        "extra_vars": local_vars,
474        "created_at": now,
475    })];
476    for (i, cred) in members.iter().enumerate() {
477        let mut member = entry_from_credential(&new_id(), &item.title, cred, now);
478        member["bundle_id"] = json!(bundle_id);
479        member["bundle_slot"] = json!(cxf_credential_kind(cred));
480        member["bundle_order"] = json!((i as i64) * 10);
481        if i == 0 {
482            apply_totp(&mut member);
483        }
484        out.push(member);
485    }
486    out
487}
488
489fn cxf_credential_kind(c: &CxfCredential) -> &'static str {
490    match c {
491        CxfCredential::BasicAuth { .. } => "basic-auth",
492        CxfCredential::ApiKey { .. } => "api-key",
493        CxfCredential::Totp { .. } => "totp",
494        CxfCredential::Note { .. } => "note",
495        CxfCredential::Wifi { .. } => "wifi",
496        CxfCredential::SshKey { .. } => "ssh-key",
497        CxfCredential::CustomFields { .. } => "custom-fields",
498    }
499}
500
501fn base_entry(id: &str, title: &str, secret_type: &str, now: &str) -> Value {
502    json!({
503        "id": id,
504        "provider": title,
505        "api_key": "",
506        "price_type": "free",
507        "secretType": secret_type,
508        "categories": [],
509        "scopes": [],
510        "projectIds": ["Universal"],
511        "extra_vars": [],
512        "created_at": now,
513    })
514}
515
516fn entry_from_credential(id: &str, title: &str, cred: &CxfCredential, now: &str) -> Value {
517    match cred {
518        CxfCredential::BasicAuth { username, password } => {
519            let mut e = base_entry(id, title, "password", now);
520            e["api_key"] = json!(password);
521            if let Some(u) = username {
522                e["account_name"] = json!(u);
523            }
524            e
525        }
526        CxfCredential::ApiKey {
527            key,
528            username,
529            key_type,
530            url,
531            expiry_date,
532        } => {
533            let secret_type = key_type
534                .as_deref()
535                .filter(|t| crate::secret_types::find(t).is_some())
536                .unwrap_or("api_key");
537            let mut e = base_entry(id, title, secret_type, now);
538            e["api_key"] = json!(key);
539            if let Some(u) = username {
540                e["account_name"] = json!(u);
541            }
542            if let Some(u) = url {
543                e["api_url"] = json!(u);
544            }
545            if let Some(x) = expiry_date {
546                e["expires_at"] = json!(x);
547            }
548            e
549        }
550        CxfCredential::Note { content } => {
551            let mut e = base_entry(id, title, "secure_note", now);
552            e["description"] = json!(content);
553            e
554        }
555        CxfCredential::SshKey {
556            private_key,
557            public_key,
558        } => {
559            let mut e = base_entry(id, title, "ssh_key", now);
560            e["api_key"] = json!(private_key);
561            if let Some(p) = public_key {
562                e["api_secret"] = json!(p);
563            }
564            e
565        }
566        CxfCredential::Wifi {
567            ssid,
568            network_security_type,
569            passphrase,
570            hidden,
571        } => {
572            let mut e = base_entry(id, ssid, "wifi", now);
573            e["account_name"] = json!(ssid);
574            if let Some(p) = passphrase {
575                e["api_key"] = json!(p);
576            }
577            let mut vars = vec![];
578            if let Some(sec) = network_security_type {
579                vars.push(json!({ "key": "security", "value": sec }));
580            }
581            if *hidden {
582                vars.push(json!({ "key": "hidden", "value": "true" }));
583            }
584            e["extra_vars"] = json!(vars);
585            e
586        }
587        CxfCredential::CustomFields { fields } => {
588            let unenverse_type = fields
589                .iter()
590                .find(|f| f.name == "_unenverse_type")
591                .map(|f| f.value.as_str())
592                .filter(|t| crate::secret_types::find(t).is_some())
593                .unwrap_or("api_key");
594            let mut e = base_entry(id, title, unenverse_type, now);
595            let mut vars = vec![];
596            for f in fields {
597                if f.name == "_unenverse_type" {
598                    continue;
599                }
600                match f.name.as_str() {
601                    "api_key" | "api_secret" | "api_url" | "key_id" | "user_agent"
602                    | "mount_path" | "composite_template" => {
603                        e[&f.name] = json!(f.value);
604                    }
605                    _ => vars.push(json!({ "key": f.name, "value": f.value })),
606                }
607            }
608            e["extra_vars"] = json!(vars);
609            e
610        }
611        CxfCredential::Totp { .. } => unreachable!("filtered out before this point"),
612    }
613}
614
615/// Parses and converts a whole document. `new_id`/`now` are injected the way
616/// every other importer in this project injects them — determinism for
617/// tests, and one clock rather than each entry stamping its own.
618pub fn import(doc: &CxfDocument, mut new_id: impl FnMut() -> String, now: &str) -> Vec<Value> {
619    doc.items
620        .iter()
621        .flat_map(|item| entries_from_item(item, &mut new_id, now))
622        .collect()
623}
624
625/// Parses a CXF JSON document from bytes.
626pub fn parse(bytes: &[u8]) -> Result<CxfDocument, String> {
627    serde_json::from_slice(bytes).map_err(|e| format!("Not a readable CXF file: {e}"))
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    fn ids() -> impl FnMut() -> String {
635        let mut n = 0;
636        move || {
637            n += 1;
638            format!("id-{n}")
639        }
640    }
641
642    #[test]
643    fn a_password_entry_round_trips_through_basic_auth() {
644        let entry = json!({
645            "id": "e1", "provider": "GitHub", "account_name": "octocat",
646            "api_key": "hunter2", "secretType": "password",
647        });
648        let doc = export(std::slice::from_ref(&entry));
649        assert_eq!(doc.items.len(), 1);
650        let imported = import(&doc, ids(), "2026-01-01T00:00:00Z");
651        assert_eq!(imported.len(), 1);
652        assert_eq!(imported[0]["provider"], "GitHub");
653        assert_eq!(imported[0]["account_name"], "octocat");
654        assert_eq!(imported[0]["api_key"], "hunter2");
655        assert_eq!(imported[0]["secretType"], "password");
656    }
657
658    #[test]
659    fn a_totp_seed_attaches_to_its_entry_rather_than_becoming_one() {
660        let entry = json!({
661            "id": "e1", "provider": "GitHub", "api_key": "hunter2",
662            "secretType": "password", "totp_secret": "JBSWY3DPEHPK3PXP",
663        });
664        let doc = export(std::slice::from_ref(&entry));
665        assert_eq!(doc.items[0].credentials.len(), 2);
666        let imported = import(&doc, ids(), "2026-01-01T00:00:00Z");
667        assert_eq!(imported.len(), 1, "the seed must not become its own entry");
668        assert_eq!(imported[0]["totp_secret"], "JBSWY3DPEHPK3PXP");
669    }
670
671    #[test]
672    fn a_type_with_no_native_mapping_round_trips_through_custom_fields() {
673        let entry = json!({
674            "id": "e1", "provider": "Postgres", "secretType": "database",
675            "api_key": "postgres://…", "extra_vars": [
676                { "key": "host", "value": "db.internal" },
677            ],
678        });
679        let doc = export(std::slice::from_ref(&entry));
680        match &doc.items[0].credentials[0] {
681            CxfCredential::CustomFields { fields } => {
682                assert!(fields
683                    .iter()
684                    .any(|f| f.name == "_unenverse_type" && f.value == "database"));
685            }
686            _ => panic!("expected custom-fields"),
687        }
688        let imported = import(&doc, ids(), "2026-01-01T00:00:00Z");
689        assert_eq!(imported[0]["secretType"], "database");
690        assert_eq!(imported[0]["api_key"], "postgres://…");
691        let vars = imported[0]["extra_vars"].as_array().unwrap();
692        assert!(vars
693            .iter()
694            .any(|v| v["key"] == "host" && v["value"] == "db.internal"));
695    }
696
697    #[test]
698    fn several_credentials_on_one_item_import_as_a_bundle() {
699        let doc = CxfDocument {
700            items: vec![CxfItem {
701                id: "item-1".into(),
702                title: "Spotify".into(),
703                subtitle: None,
704                favorite: false,
705                tags: vec![],
706                scope: None,
707                credentials: vec![
708                    CxfCredential::BasicAuth {
709                        username: Some("me".into()),
710                        password: "pw".into(),
711                    },
712                    CxfCredential::ApiKey {
713                        key: "client-secret".into(),
714                        username: None,
715                        key_type: Some("api_key".into()),
716                        url: None,
717                        expiry_date: None,
718                    },
719                ],
720            }],
721        };
722        let imported = import(&doc, ids(), "2026-01-01T00:00:00Z");
723        assert_eq!(imported.len(), 3, "one bundle parent + two members");
724        assert_eq!(imported[0]["secretType"], "bundle");
725        assert_eq!(imported[1]["bundle_id"], imported[0]["id"]);
726        assert_eq!(imported[2]["bundle_id"], imported[0]["id"]);
727    }
728
729    #[test]
730    fn cxf_custom_fields_become_bundle_locals_not_a_member() {
731        let doc = CxfDocument {
732            items: vec![CxfItem {
733                id: "item-1".into(),
734                title: "Discord bot".into(),
735                subtitle: None,
736                favorite: false,
737                tags: vec![],
738                scope: None,
739                credentials: vec![
740                    CxfCredential::ApiKey {
741                        key: "bot-token".into(),
742                        username: None,
743                        key_type: Some("api_key".into()),
744                        url: None,
745                        expiry_date: None,
746                    },
747                    CxfCredential::CustomFields {
748                        fields: vec![
749                            CxfField {
750                                name: "application_id".into(),
751                                value: "9007199254740993".into(),
752                                field_type: Some("text".into()),
753                            },
754                            CxfField {
755                                name: "_unenverse_type".into(),
756                                value: "api_key".into(),
757                                field_type: None,
758                            },
759                        ],
760                    },
761                ],
762            }],
763        };
764        let imported = import(&doc, ids(), "2026-01-01T00:00:00Z");
765        assert_eq!(imported.len(), 2, "bundle parent plus actual credential");
766        assert_eq!(imported[0]["secretType"], "bundle");
767        assert_eq!(imported[0]["extra_vars"][0]["key"], "application_id");
768        assert_eq!(imported[0]["extra_vars"][0]["value"], "9007199254740993");
769        assert_eq!(imported[1]["bundle_id"], imported[0]["id"]);
770    }
771
772    #[test]
773    fn parsing_junk_bytes_refuses_rather_than_panics() {
774        assert!(parse(b"{not json").is_err());
775    }
776
777    #[test]
778    fn exporting_a_bundle_and_reimporting_keeps_membership() {
779        let bundle = json!({
780            "id": "b1", "provider": "Spotify", "secretType": "bundle",
781            "extra_vars": [{ "key": "region", "value": "eu-west" }],
782        });
783        let m1 = json!({
784            "id": "m1", "provider": "Spotify", "secretType": "password",
785            "api_key": "pw", "bundle_id": "b1",
786        });
787        let m2 = json!({
788            "id": "m2", "provider": "Spotify", "secretType": "api_key",
789            "api_key": "sk", "bundle_id": "b1",
790        });
791        let doc = export(&[bundle, m1, m2]);
792        assert_eq!(doc.items.len(), 1);
793        assert_eq!(doc.items[0].credentials.len(), 3);
794        let imported = import(&doc, ids(), "2026-01-01T00:00:00Z");
795        assert_eq!(imported.len(), 3);
796        assert_eq!(imported[0]["extra_vars"][0]["key"], "region");
797        assert_eq!(imported[0]["extra_vars"][0]["value"], "eu-west");
798    }
799
800    #[test]
801    fn empty_bundle_with_locals_round_trips_as_a_bundle() {
802        let bundle = json!({
803            "id": "b1", "provider": "Discord", "secretType": "bundle",
804            "extra_vars": [
805                { "key": "guild_id", "value": "123" },
806                { "key": "_unenverse_bundle", "value": "true" },
807            ],
808        });
809        let doc = export(&[bundle]);
810        assert_eq!(doc.items[0].credentials.len(), 1);
811        let imported = import(&doc, ids(), "2026-01-01T00:00:00Z");
812        assert_eq!(imported.len(), 1);
813        assert_eq!(imported[0]["secretType"], "bundle");
814        assert_eq!(imported[0]["extra_vars"][0]["key"], "guild_id");
815        assert_eq!(imported[0]["extra_vars"][1]["key"], "_unenverse_bundle");
816        assert_eq!(imported[0]["extra_vars"][1]["value"], "true");
817    }
818}