Skip to main content

murk_cli/
recipients.rs

1//! Recipient management: authorize, revoke, and list vault recipients.
2
3use crate::{crypto, types};
4
5/// Maximum number of recipients per vault.
6const MAX_RECIPIENTS: usize = 100;
7
8/// A single recipient entry with resolved display info.
9#[derive(Debug)]
10pub struct RecipientEntry {
11    pub pubkey: String,
12    pub display_name: Option<String>,
13    pub is_self: bool,
14}
15
16/// List all recipients in the vault with optional name resolution.
17///
18/// If `secret_key` is provided, decrypts meta to resolve display names
19/// and marks which recipient corresponds to the caller's key.
20pub fn list_recipients(vault: &types::Vault, secret_key: Option<&str>) -> Vec<RecipientEntry> {
21    let meta_data = secret_key.filter(|k| !k.is_empty()).and_then(|sk| {
22        let identity = crypto::parse_identity(sk).ok()?;
23        let my_pubkey = identity.pubkey_string().ok()?;
24        let meta = crate::decrypt_meta(vault, &identity)?;
25        Some((meta, my_pubkey))
26    });
27
28    vault
29        .recipients
30        .iter()
31        .map(|pk| {
32            let (display_name, is_self) = match &meta_data {
33                Some((meta, my_pubkey)) => {
34                    let name = meta.recipients.get(pk).filter(|n| !n.is_empty()).cloned();
35                    (name, pk == my_pubkey)
36                }
37                None => (None, false),
38            };
39            RecipientEntry {
40                pubkey: pk.clone(),
41                display_name,
42                is_self,
43            }
44        })
45        .collect()
46}
47
48/// Add a recipient to the vault. Returns an error if the pubkey is invalid or already present.
49pub fn authorize_recipient(
50    vault: &mut types::Vault,
51    murk: &mut types::Murk,
52    pubkey: &str,
53    name: Option<&str>,
54) -> Result<(), crate::error::MurkError> {
55    use crate::error::MurkError;
56
57    if crypto::parse_recipient(pubkey).is_err() {
58        return Err(MurkError::Recipient(format!(
59            "invalid public key: {pubkey}"
60        )));
61    }
62
63    if vault.recipients.contains(&pubkey.to_string()) {
64        return Err(MurkError::Recipient(format!(
65            "{pubkey} is already a recipient"
66        )));
67    }
68
69    if vault.recipients.len() >= MAX_RECIPIENTS {
70        return Err(MurkError::Recipient(format!(
71            "vault already has {MAX_RECIPIENTS} recipients — remove unused recipients before adding more"
72        )));
73    }
74
75    vault.recipients.push(pubkey.into());
76
77    if let Some(n) = name {
78        murk.recipients.insert(pubkey.into(), n.into());
79    }
80
81    Ok(())
82}
83
84/// Result of revoking a recipient.
85#[derive(Debug)]
86pub struct RevokeResult {
87    /// The display name of the revoked recipient, if known.
88    pub display_name: Option<String>,
89    /// Keys the revoked recipient had access to (for rotation warnings).
90    pub exposed_keys: Vec<String>,
91}
92
93/// Remove a recipient from the vault. `recipient` can be a pubkey or a display name.
94///
95/// When matched by display name, removes **all** recipients sharing that name
96/// (e.g. multiple SSH keys added via `github:username`).
97/// Returns an error if the recipient is not found or would remove the last recipient.
98pub fn revoke_recipient(
99    vault: &mut types::Vault,
100    murk: &mut types::Murk,
101    recipient: &str,
102) -> Result<RevokeResult, crate::error::MurkError> {
103    use crate::error::MurkError;
104
105    let pubkeys: Vec<String> = if vault.recipients.contains(&recipient.to_string()) {
106        vec![recipient.to_string()]
107    } else {
108        let matched: Vec<String> = murk
109            .recipients
110            .iter()
111            .filter(|(_, name)| name.as_str() == recipient)
112            .map(|(pk, _)| pk.clone())
113            .collect();
114        if matched.is_empty() {
115            return Err(MurkError::Recipient(format!(
116                "recipient not found: {recipient}"
117            )));
118        }
119        if matched.len() > 1 {
120            return Err(MurkError::Recipient(format!(
121                "ambiguous name \"{recipient}\" matches {} recipients — use a pubkey to revoke",
122                matched.len()
123            )));
124        }
125        matched
126    };
127
128    if vault.recipients.len() <= pubkeys.len() {
129        return Err(MurkError::Recipient(
130            "cannot revoke last recipient — vault would become permanently inaccessible".into(),
131        ));
132    }
133
134    // Groups any revoked pubkey belonged to (captured before removal below).
135    let revoked_groups: std::collections::BTreeSet<String> = murk
136        .groups
137        .iter()
138        .filter(|(_, members)| members.iter().any(|pk| pubkeys.contains(pk)))
139        .map(|(name, _)| name.clone())
140        .collect();
141
142    let mut display_name = None;
143    for pubkey in &pubkeys {
144        vault.recipients.retain(|pk| pk != pubkey);
145
146        if let Some(name) = murk.recipients.remove(pubkey) {
147            display_name = Some(name);
148        }
149
150        // Remove their scoped entries.
151        for scoped_map in murk.private.values_mut() {
152            scoped_map.remove(pubkey);
153        }
154        for entry in vault.secrets.values_mut() {
155            entry.private.remove(pubkey);
156        }
157
158        // Remove them from any groups they belonged to. save_vault re-encrypts
159        // affected groups (when the operator is a member and can read them).
160        for members in murk.groups.values_mut() {
161            members.retain(|pk| pk != pubkey);
162        }
163    }
164
165    // Drop any agent grant whose ephemeral pubkey was just revoked, so revoking
166    // an agent recipient (directly or via `agent revoke`) never leaves an
167    // orphaned grant record pointing at a pubkey that is no longer a recipient.
168    murk.grants
169        .retain(|_, grant| !pubkeys.contains(&grant.pubkey));
170
171    // Only report keys the revoked recipient could actually decrypt: shared
172    // secrets (all recipients can read), their scoped entries, and any group
173    // they were a member of.
174    let exposed_keys: Vec<String> = vault
175        .secrets
176        .iter()
177        .filter(|(_, entry)| {
178            !entry.shared.is_empty()
179                || pubkeys.iter().any(|pk| entry.private.contains_key(pk))
180                || entry.grouped.keys().any(|g| revoked_groups.contains(g))
181        })
182        .map(|(key, _)| key.clone())
183        .collect();
184
185    Ok(RevokeResult {
186        display_name,
187        exposed_keys,
188    })
189}
190
191/// Truncate a pubkey for display, keeping start and end.
192pub fn truncate_pubkey(pk: &str) -> String {
193    if let Some(key_data) = pk.strip_prefix("ssh-ed25519 ") {
194        return truncate_raw(key_data);
195    }
196    if let Some(key_data) = pk.strip_prefix("ssh-rsa ") {
197        return truncate_raw(key_data);
198    }
199    truncate_raw(pk)
200}
201
202fn truncate_raw(s: &str) -> String {
203    if s.len() <= 13 {
204        return s.to_string();
205    }
206    let start: String = s.chars().take(8).collect();
207    let end: String = s
208        .chars()
209        .rev()
210        .take(4)
211        .collect::<Vec<_>>()
212        .into_iter()
213        .rev()
214        .collect();
215    format!("{start}…{end}")
216}
217
218/// Return the key type label for a pubkey string.
219pub fn key_type_label(pk: &str) -> &'static str {
220    if pk.starts_with("ssh-ed25519 ") {
221        "ed25519"
222    } else if pk.starts_with("ssh-rsa ") {
223        "rsa"
224    } else {
225        "age"
226    }
227}
228
229/// A group of recipients sharing a display name.
230pub struct RecipientGroup<'a> {
231    pub name: Option<&'a str>,
232    pub entries: Vec<&'a RecipientEntry>,
233    pub is_self: bool,
234}
235
236/// Group recipient entries by display name and format for display.
237/// Returns plain-text lines (no ANSI colors).
238pub fn format_recipient_lines(entries: &[RecipientEntry]) -> Vec<String> {
239    let has_names = entries.iter().any(|e| e.display_name.is_some());
240    if !has_names {
241        return entries.iter().map(|e| e.pubkey.clone()).collect();
242    }
243
244    let groups = group_recipients(entries);
245
246    let name_width = groups
247        .iter()
248        .map(|g| g.name.map_or(0, str::len))
249        .max()
250        .unwrap_or(0);
251
252    groups
253        .iter()
254        .map(|g| {
255            let marker = if g.is_self { "◆" } else { " " };
256            let label = g.name.unwrap_or("");
257            let label_padded = format!("{label:<name_width$}");
258            let key_type = key_type_label(&g.entries[0].pubkey);
259            let key_info = if g.entries.len() == 1 {
260                truncate_pubkey(&g.entries[0].pubkey)
261            } else {
262                format!("({} keys)", g.entries.len())
263            };
264            format!("{marker} {label_padded}  {key_info}  {key_type}")
265        })
266        .collect()
267}
268
269fn group_recipients(entries: &[RecipientEntry]) -> Vec<RecipientGroup<'_>> {
270    let mut groups: Vec<RecipientGroup<'_>> = Vec::new();
271    for entry in entries {
272        let name = entry.display_name.as_deref();
273        if let Some(group) = groups.iter_mut().find(|g| g.name == name && name.is_some()) {
274            group.entries.push(entry);
275            if entry.is_self {
276                group.is_self = true;
277            }
278        } else {
279            groups.push(RecipientGroup {
280                name,
281                entries: vec![entry],
282                is_self: entry.is_self,
283            });
284        }
285    }
286    groups
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::testutil::*;
293    use crate::types;
294    use std::collections::{BTreeMap, HashMap};
295
296    #[test]
297    fn authorize_recipient_success() {
298        let (_, pubkey) = generate_keypair();
299        let mut vault = empty_vault();
300        let mut murk = empty_murk();
301
302        let result = authorize_recipient(&mut vault, &mut murk, &pubkey, Some("alice"));
303        assert!(result.is_ok());
304        assert!(vault.recipients.contains(&pubkey));
305        assert_eq!(murk.recipients[&pubkey], "alice");
306    }
307
308    #[test]
309    fn authorize_recipient_no_name() {
310        let (_, pubkey) = generate_keypair();
311        let mut vault = empty_vault();
312        let mut murk = empty_murk();
313
314        authorize_recipient(&mut vault, &mut murk, &pubkey, None).unwrap();
315        assert!(vault.recipients.contains(&pubkey));
316        assert!(!murk.recipients.contains_key(&pubkey));
317    }
318
319    #[test]
320    fn authorize_recipient_duplicate_fails() {
321        let (_, pubkey) = generate_keypair();
322        let mut vault = empty_vault();
323        vault.recipients.push(pubkey.clone());
324        let mut murk = empty_murk();
325
326        let result = authorize_recipient(&mut vault, &mut murk, &pubkey, None);
327        assert!(result.is_err());
328        assert!(
329            result
330                .unwrap_err()
331                .to_string()
332                .contains("already a recipient")
333        );
334    }
335
336    #[test]
337    fn authorize_recipient_invalid_key_fails() {
338        let mut vault = empty_vault();
339        let mut murk = empty_murk();
340
341        let result = authorize_recipient(&mut vault, &mut murk, "not-a-valid-key", None);
342        assert!(result.is_err());
343        assert!(
344            result
345                .unwrap_err()
346                .to_string()
347                .contains("invalid public key")
348        );
349    }
350
351    #[test]
352    fn revoke_recipient_by_pubkey() {
353        let (_, pk1) = generate_keypair();
354        let (_, pk2) = generate_keypair();
355        let mut vault = empty_vault();
356        vault.recipients = vec![pk1.clone(), pk2.clone()];
357        vault.schema.insert(
358            "KEY".into(),
359            types::SchemaEntry {
360                description: String::new(),
361                example: None,
362                tags: vec![],
363                ..Default::default()
364            },
365        );
366        vault.secrets.insert(
367            "KEY".into(),
368            types::SecretEntry {
369                shared: "ciphertext".into(),
370                private: std::collections::BTreeMap::new(),
371                grouped: std::collections::BTreeMap::default(),
372            },
373        );
374        let mut murk = empty_murk();
375        murk.recipients.insert(pk2.clone(), "bob".into());
376
377        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
378        assert_eq!(result.display_name.as_deref(), Some("bob"));
379        assert!(!vault.recipients.contains(&pk2));
380        assert!(vault.recipients.contains(&pk1));
381        assert_eq!(result.exposed_keys, vec!["KEY"]);
382    }
383
384    #[test]
385    fn revoke_recipient_by_name() {
386        let (_, pk1) = generate_keypair();
387        let (_, pk2) = generate_keypair();
388        let mut vault = empty_vault();
389        vault.recipients = vec![pk1.clone(), pk2.clone()];
390        let mut murk = empty_murk();
391        murk.recipients.insert(pk2.clone(), "bob".into());
392
393        let result = revoke_recipient(&mut vault, &mut murk, "bob").unwrap();
394        assert_eq!(result.display_name.as_deref(), Some("bob"));
395        assert!(!vault.recipients.contains(&pk2));
396    }
397
398    #[test]
399    fn revoke_recipient_last_fails() {
400        let (_, pk) = generate_keypair();
401        let mut vault = empty_vault();
402        vault.recipients = vec![pk.clone()];
403        let mut murk = empty_murk();
404
405        let result = revoke_recipient(&mut vault, &mut murk, &pk);
406        assert!(result.is_err());
407        assert!(
408            result
409                .unwrap_err()
410                .to_string()
411                .contains("cannot revoke last recipient")
412        );
413    }
414
415    #[test]
416    fn revoke_recipient_unknown_fails() {
417        let (_, pk) = generate_keypair();
418        let mut vault = empty_vault();
419        vault.recipients = vec![pk.clone()];
420        let mut murk = empty_murk();
421
422        let result = revoke_recipient(&mut vault, &mut murk, "nobody");
423        assert!(result.is_err());
424        assert!(
425            result
426                .unwrap_err()
427                .to_string()
428                .contains("recipient not found")
429        );
430    }
431
432    #[test]
433    fn revoke_recipient_removes_scoped() {
434        let (_, pk1) = generate_keypair();
435        let (_, pk2) = generate_keypair();
436        let mut vault = empty_vault();
437        vault.recipients = vec![pk1.clone(), pk2.clone()];
438        vault.secrets.insert(
439            "KEY".into(),
440            types::SecretEntry {
441                shared: "ct".into(),
442                private: BTreeMap::from([(pk2.clone(), "scoped_ct".into())]),
443                grouped: std::collections::BTreeMap::default(),
444            },
445        );
446        let mut murk = empty_murk();
447        let mut scoped = HashMap::new();
448        scoped.insert(pk2.clone(), secret("scoped_val"));
449        murk.private.insert("KEY".into(), scoped);
450
451        revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
452
453        assert!(vault.secrets["KEY"].private.is_empty());
454        assert!(murk.private["KEY"].is_empty());
455    }
456
457    #[test]
458    fn revoke_recipient_reports_exposed_keys() {
459        let (_, pk1) = generate_keypair();
460        let (_, pk2) = generate_keypair();
461        let mut vault = empty_vault();
462        vault.recipients = vec![pk1.clone(), pk2.clone()];
463        // exposed_keys returns all schema keys, so we need schema entries.
464        vault.schema.insert(
465            "DB_URL".into(),
466            types::SchemaEntry {
467                description: "db".into(),
468                example: None,
469                tags: vec![],
470                ..Default::default()
471            },
472        );
473        vault.schema.insert(
474            "API_KEY".into(),
475            types::SchemaEntry {
476                description: "api".into(),
477                example: None,
478                tags: vec![],
479                ..Default::default()
480            },
481        );
482        vault.secrets.insert(
483            "DB_URL".into(),
484            types::SecretEntry {
485                shared: "ct".into(),
486                private: BTreeMap::from([(pk2.clone(), "scoped_db".into())]),
487                grouped: std::collections::BTreeMap::default(),
488            },
489        );
490        vault.secrets.insert(
491            "API_KEY".into(),
492            types::SecretEntry {
493                shared: "ct2".into(),
494                private: BTreeMap::from([(pk2.clone(), "scoped_api".into())]),
495                grouped: std::collections::BTreeMap::default(),
496            },
497        );
498        let mut murk = empty_murk();
499        murk.private
500            .insert("DB_URL".into(), HashMap::from([(pk2.clone(), secret("v"))]));
501        murk.private.insert(
502            "API_KEY".into(),
503            HashMap::from([(pk2.clone(), secret("v2"))]),
504        );
505
506        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
507        let mut keys = result.exposed_keys.clone();
508        keys.sort();
509        assert_eq!(keys, vec!["API_KEY", "DB_URL"]);
510        assert!(vault.secrets["DB_URL"].private.is_empty());
511        assert!(vault.secrets["API_KEY"].private.is_empty());
512    }
513
514    // ── list_recipients tests ──
515
516    #[test]
517    fn list_recipients_with_meta() {
518        let (secret, pubkey) = generate_keypair();
519        let (_, pk2) = generate_keypair();
520        let recipient = make_recipient(&pubkey);
521
522        let mut names = std::collections::HashMap::new();
523        names.insert(pubkey.clone(), "Alice".to_string());
524        names.insert(pk2.clone(), "Bob".to_string());
525        let meta = types::Meta {
526            recipients: names,
527            mac: String::new(),
528            mac_key: None,
529            github_pins: HashMap::new(),
530            ..Default::default()
531        };
532        let meta_json = serde_json::to_vec(&meta).unwrap();
533        let r2 = make_recipient(&pk2);
534        let meta_enc = crate::encrypt_value(&meta_json, &[recipient, r2]).unwrap();
535
536        let mut vault = empty_vault();
537        vault.recipients = vec![pubkey.clone(), pk2.clone()];
538        vault.meta = meta_enc;
539
540        let entries = list_recipients(&vault, Some(&secret));
541        assert_eq!(entries.len(), 2);
542        let me = entries.iter().find(|e| e.pubkey == pubkey).unwrap();
543        assert!(me.is_self);
544        assert_eq!(me.display_name.as_deref(), Some("Alice"));
545        let other = entries.iter().find(|e| e.pubkey == pk2).unwrap();
546        assert!(!other.is_self);
547        assert_eq!(other.display_name.as_deref(), Some("Bob"));
548    }
549
550    #[test]
551    fn list_recipients_without_key() {
552        let (_, pubkey) = generate_keypair();
553        let mut vault = empty_vault();
554        vault.recipients = vec![pubkey.clone()];
555
556        let entries = list_recipients(&vault, None);
557        assert_eq!(entries.len(), 1);
558        assert_eq!(entries[0].pubkey, pubkey);
559        assert!(entries[0].display_name.is_none());
560        assert!(!entries[0].is_self);
561    }
562
563    #[test]
564    fn list_recipients_wrong_key() {
565        let (_, pubkey) = generate_keypair();
566        let recipient = make_recipient(&pubkey);
567        let (wrong_secret, _) = generate_keypair();
568
569        let meta = types::Meta {
570            recipients: std::collections::HashMap::from([(pubkey.clone(), "Alice".into())]),
571            mac: String::new(),
572            mac_key: None,
573            github_pins: HashMap::new(),
574            ..Default::default()
575        };
576        let meta_json = serde_json::to_vec(&meta).unwrap();
577        let meta_enc = crate::encrypt_value(&meta_json, &[recipient]).unwrap();
578
579        let mut vault = empty_vault();
580        vault.recipients = vec![pubkey.clone()];
581        vault.meta = meta_enc;
582
583        let entries = list_recipients(&vault, Some(&wrong_secret));
584        assert_eq!(entries.len(), 1);
585        assert!(entries[0].display_name.is_none());
586        assert!(!entries[0].is_self);
587    }
588
589    #[test]
590    fn list_recipients_empty_vault() {
591        let vault = empty_vault();
592        let entries = list_recipients(&vault, None);
593        assert!(entries.is_empty());
594    }
595
596    #[test]
597    fn revoke_recipient_no_scoped() {
598        let (_, pk1) = generate_keypair();
599        let (_, pk2) = generate_keypair();
600        let mut vault = empty_vault();
601        vault.recipients = vec![pk1.clone(), pk2.clone()];
602        let mut murk = empty_murk();
603        murk.recipients.insert(pk2.clone(), "bob".into());
604
605        let result = revoke_recipient(&mut vault, &mut murk, &pk2).unwrap();
606        assert_eq!(result.display_name.as_deref(), Some("bob"));
607        assert!(!vault.recipients.contains(&pk2));
608    }
609
610    #[test]
611    fn revoke_by_name_rejects_ambiguous_match() {
612        let (_, pk_owner) = generate_keypair();
613        let (_, pk_ssh1) = generate_keypair();
614        let (_, pk_ssh2) = generate_keypair();
615        let mut vault = empty_vault();
616        vault.recipients = vec![pk_owner.clone(), pk_ssh1.clone(), pk_ssh2.clone()];
617        let mut murk = empty_murk();
618        murk.recipients
619            .insert(pk_ssh1.clone(), "alice@github".into());
620        murk.recipients
621            .insert(pk_ssh2.clone(), "alice@github".into());
622
623        let result = revoke_recipient(&mut vault, &mut murk, "alice@github");
624        assert!(result.is_err());
625        assert!(result.unwrap_err().to_string().contains("ambiguous name"));
626    }
627
628    // ── formatting tests ──
629
630    #[test]
631    fn truncate_age_key() {
632        let pk = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p";
633        let truncated = truncate_pubkey(pk);
634        assert!(truncated.len() < pk.len());
635        assert!(truncated.starts_with("age1ql3z"));
636        assert!(truncated.contains('…'));
637    }
638
639    #[test]
640    fn truncate_ssh_key() {
641        let pk = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVsample";
642        let truncated = truncate_pubkey(pk);
643        assert!(!truncated.starts_with("ssh-ed25519"));
644        assert!(truncated.contains('…'));
645    }
646
647    #[test]
648    fn truncate_short_key_unchanged() {
649        assert_eq!(truncate_pubkey("age1short"), "age1short");
650    }
651
652    #[test]
653    fn key_type_labels() {
654        assert_eq!(key_type_label("age1abc"), "age");
655        assert_eq!(key_type_label("ssh-ed25519 AAAA"), "ed25519");
656        assert_eq!(key_type_label("ssh-rsa AAAA"), "rsa");
657    }
658
659    #[test]
660    fn format_recipients_no_names() {
661        let entries = vec![
662            RecipientEntry {
663                pubkey: "age1abc".into(),
664                display_name: None,
665                is_self: false,
666            },
667            RecipientEntry {
668                pubkey: "age1xyz".into(),
669                display_name: None,
670                is_self: false,
671            },
672        ];
673        let lines = format_recipient_lines(&entries);
674        assert_eq!(lines, vec!["age1abc", "age1xyz"]);
675    }
676
677    #[test]
678    fn format_recipients_with_names() {
679        let entries = vec![
680            RecipientEntry {
681                pubkey: "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
682                display_name: Some("alice".into()),
683                is_self: true,
684            },
685            RecipientEntry {
686                pubkey: "age1xyz7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p".into(),
687                display_name: Some("bob".into()),
688                is_self: false,
689            },
690        ];
691        let lines = format_recipient_lines(&entries);
692        assert_eq!(lines.len(), 2);
693        assert!(lines[0].starts_with("◆"));
694        assert!(lines[0].contains("alice"));
695        assert!(lines[1].starts_with(' '));
696        assert!(lines[1].contains("bob"));
697    }
698
699    #[test]
700    fn format_recipients_groups_multi_key() {
701        let entries = vec![
702            RecipientEntry {
703                pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey1sample".into(),
704                display_name: Some("alice@github".into()),
705                is_self: false,
706            },
707            RecipientEntry {
708                pubkey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGVkey2sample".into(),
709                display_name: Some("alice@github".into()),
710                is_self: false,
711            },
712        ];
713        let lines = format_recipient_lines(&entries);
714        assert_eq!(lines.len(), 1);
715        assert!(lines[0].contains("(2 keys)"));
716    }
717}