1use serde::{Deserialize, Serialize};
42use serde_json::{json, Value};
43
44#[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
137fn 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
165fn 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
213const 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
252fn 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
332pub 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
385fn 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 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 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
615pub 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
625pub 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}