Skip to main content

tsafe_nativehost/
lib.rs

1//! tsafe Native Messaging Host — library entry point exposed for the tsafe meta-crate.
2//!
3//! All host logic is defined here so the meta-crate can link against it.
4//! `main.rs` is a thin shim that calls [`run`].
5
6use std::collections::HashMap;
7use std::io::{self, Read, Write};
8
9use chrono::Utc;
10use serde_json::{json, Value};
11use tsafe_core::{
12    audit::{AuditEntry, AuditLog},
13    errors::SafeError,
14    profile,
15    vault::Vault,
16};
17use zeroize::Zeroize;
18
19struct Session {
20    token: String,
21    profile: String,
22    password: String,
23    expires_at: chrono::DateTime<Utc>,
24}
25
26impl Drop for Session {
27    fn drop(&mut self) {
28        self.password.zeroize();
29        self.token.zeroize();
30    }
31}
32
33fn message_hostname(msg: &Value) -> Option<&str> {
34    msg["hostname"]
35        .as_str()
36        .map(str::trim)
37        .filter(|s| !s.is_empty())
38}
39
40fn validated_message_hostname(msg: &Value) -> Result<&str, Value> {
41    let hostname = message_hostname(msg)
42        .ok_or_else(|| json!({"status": "error", "message": "missing hostname"}))?;
43    if let Some(err) = browser_hostname_rpc_error(Some(hostname)) {
44        return Err(err);
45    }
46    Ok(hostname)
47}
48
49fn browser_request_allowed(session: &Session, hostname: &str) -> bool {
50    matches!(
51        profile::resolve_browser_profile(hostname),
52        Ok(Some(mapped_profile)) if mapped_profile == session.profile
53    )
54}
55
56/// Reject structurally invalid hostnames before profile mapping (see [`profile::browser_hostname_fill_guard`]).
57fn browser_hostname_rpc_error(hostname: Option<&str>) -> Option<Value> {
58    let h = hostname?;
59    profile::browser_hostname_fill_guard(h)
60        .err()
61        .map(|reason| json!({"status": "error", "message": format!("hostname rejected: {reason}")}))
62}
63
64fn parse_json_body(body: &[u8]) -> anyhow::Result<Value> {
65    Ok(serde_json::from_slice(body)?)
66}
67
68/// Read one Chrome native-messaging frame: 4-byte little-endian length + UTF-8 JSON body.
69pub(crate) fn read_message_from<R: Read>(mut r: R) -> anyhow::Result<Value> {
70    let mut len_buf = [0u8; 4];
71    r.read_exact(&mut len_buf)?;
72    let len = u32::from_le_bytes(len_buf) as usize;
73    if len > 1_048_576 {
74        anyhow::bail!("message too large: {len}");
75    }
76    let mut body = vec![0u8; len];
77    r.read_exact(&mut body)?;
78    parse_json_body(&body)
79}
80
81fn read_message() -> anyhow::Result<Value> {
82    read_message_from(io::stdin().lock())
83}
84
85fn write_message(resp: &Value) -> anyhow::Result<()> {
86    let bytes = serde_json::to_vec(resp)?;
87    let len = bytes.len() as u32;
88    io::stdout().write_all(&len.to_le_bytes())?;
89    io::stdout().write_all(&bytes)?;
90    io::stdout().flush()?;
91    Ok(())
92}
93
94/// Check whether `token` matches the active session and has not expired.
95/// Refreshes the TTL on success; drops the session on expiry.
96fn check_session(session: &mut Option<Session>, token: &str) -> bool {
97    if let Some(ref mut s) = session {
98        if s.token == token {
99            if Utc::now() < s.expires_at {
100                s.expires_at = Utc::now() + chrono::Duration::minutes(5);
101                return true;
102            } else {
103                *session = None;
104            }
105        }
106    }
107    false
108}
109
110fn append_browser_audit(profile_name: &str, operation: &str, key: Option<&str>) {
111    AuditLog::new(&profile::audit_log_path(profile_name))
112        .append(&AuditEntry::success(profile_name, operation, key))
113        .ok();
114}
115
116pub(crate) fn dispatch(session: &mut Option<Session>, msg: Value) -> Value {
117    let command = msg["command"].as_str().unwrap_or("");
118    match command {
119        "unlock" => {
120            let profile_name = msg["profile"].as_str().unwrap_or("default").to_string();
121            if profile::validate_profile_name(&profile_name).is_err() {
122                return json!({"status": "error", "message": "invalid profile name"});
123            }
124            let password = match msg["password"].as_str() {
125                Some(p) => p.to_string(),
126                None => return json!({"status": "error", "message": "missing password"}),
127            };
128            let vault_path = profile::vault_path(&profile_name);
129            match Vault::open(&vault_path, password.as_bytes()) {
130                Ok(_) => {
131                    let token = uuid::Uuid::new_v4().to_string();
132                    let expires_at = Utc::now() + chrono::Duration::minutes(5);
133                    *session = Some(Session {
134                        token: token.clone(),
135                        profile: profile_name.clone(),
136                        password,
137                        expires_at,
138                    });
139                    json!({
140                        "status": "ok",
141                        "session_token": token,
142                        "profile": profile_name,
143                        "expires_at": expires_at.to_rfc3339(),
144                    })
145                }
146                Err(SafeError::DecryptionFailed) => {
147                    json!({"status": "error", "message": "wrong password"})
148                }
149                Err(SafeError::VaultNotFound { .. }) => {
150                    json!({"status": "error", "message": format!("no vault for profile '{profile_name}'")})
151                }
152                Err(e) => json!({"status": "error", "message": e.to_string()}),
153            }
154        }
155
156        "lock" => {
157            *session = None;
158            json!({"status": "ok"})
159        }
160
161        "list_logins" => {
162            let token = msg["session_token"].as_str().unwrap_or("");
163            if !check_session(session, token) {
164                return json!({"status": "error", "message": "session expired"});
165            }
166            let sess = session.as_ref().unwrap();
167            let hostname = match validated_message_hostname(&msg) {
168                Ok(hostname) => hostname,
169                Err(err) => return err,
170            };
171            if !browser_request_allowed(sess, hostname) {
172                // Before returning a generic "not mapped" error, check whether the
173                // hostname is a typosquat of a registered domain (phishing guard).
174                let profiles = profile::load_browser_profiles().unwrap_or_default();
175                if let Some(m) = profile::lookalike_check(hostname, &profiles) {
176                    return json!({
177                        "status": "phishing_warning",
178                        "registered": m.registered,
179                    });
180                }
181                return json!({"status": "error", "message": "domain is not mapped to the unlocked profile"});
182            }
183            let vault_path = profile::vault_path(&sess.profile);
184            match Vault::open(&vault_path, sess.password.as_bytes()) {
185                Ok(vault) => {
186                    let mut logins: Vec<Value> = vault
187                        .file()
188                        .secrets
189                        .iter()
190                        .filter(|(_, e)| {
191                            // Exclude alias entries from the logins list.
192                            e.tags.get("type").map(|t| t != "alias").unwrap_or(true)
193                        })
194                        .map(|(k, e)| {
195                            let pinned = e.tags.get("pinned").map(|v| v == "true").unwrap_or(false);
196                            json!({"key": k, "pinned": pinned})
197                        })
198                        .collect();
199                    // Pinned first, then alphabetical.
200                    logins.sort_by(|a, b| {
201                        let ap = a["pinned"].as_bool().unwrap_or(false);
202                        let bp = b["pinned"].as_bool().unwrap_or(false);
203                        bp.cmp(&ap).then_with(|| {
204                            a["key"]
205                                .as_str()
206                                .unwrap_or("")
207                                .cmp(b["key"].as_str().unwrap_or(""))
208                        })
209                    });
210                    append_browser_audit(&sess.profile, "browser-list", None);
211                    json!({"status": "ok", "logins": logins})
212                }
213                Err(e) => json!({"status": "error", "message": e.to_string()}),
214            }
215        }
216
217        "get_login" => {
218            let token = msg["session_token"].as_str().unwrap_or("");
219            if !check_session(session, token) {
220                return json!({"status": "error", "message": "session expired"});
221            }
222            let key = match msg["key"].as_str() {
223                Some(k) => k.to_string(),
224                None => return json!({"status": "error", "message": "missing key"}),
225            };
226            let sess = session.as_ref().unwrap();
227            let hostname = match validated_message_hostname(&msg) {
228                Ok(hostname) => hostname,
229                Err(err) => return err,
230            };
231            if !browser_request_allowed(sess, hostname) {
232                return json!({"status": "error", "message": "domain is not mapped to the unlocked profile"});
233            }
234            let vault_path = profile::vault_path(&sess.profile);
235            match Vault::open(&vault_path, sess.password.as_bytes()) {
236                Ok(vault) => match vault.get(&key) {
237                    Ok(value) => {
238                        append_browser_audit(&sess.profile, "browser-get", Some(&key));
239                        json!({"status": "ok", "value": value.as_ref() as &str})
240                    }
241                    Err(_) => json!({"status": "error", "message": "key not found"}),
242                },
243                Err(e) => json!({"status": "error", "message": e.to_string()}),
244            }
245        }
246
247        "save_login" => {
248            let token = msg["session_token"].as_str().unwrap_or("");
249            if !check_session(session, token) {
250                return json!({"status": "error", "message": "session expired"});
251            }
252            let key = match msg["key"].as_str() {
253                Some(k) => k.to_string(),
254                None => return json!({"status": "error", "message": "missing key"}),
255            };
256            let value = match msg["value"].as_str() {
257                Some(v) => v.to_string(),
258                None => return json!({"status": "error", "message": "missing value"}),
259            };
260            let sess = session.as_ref().unwrap();
261            let hostname = match validated_message_hostname(&msg) {
262                Ok(hostname) => hostname,
263                Err(err) => return err,
264            };
265            if !browser_request_allowed(sess, hostname) {
266                return json!({"status": "error", "message": "domain is not mapped to the unlocked profile"});
267            }
268            let vault_path = profile::vault_path(&sess.profile);
269            match Vault::open(&vault_path, sess.password.as_bytes()) {
270                Ok(mut vault) => match vault.set(&key, &value, HashMap::new()) {
271                    Ok(()) => {
272                        append_browser_audit(&sess.profile, "browser-save", Some(&key));
273                        json!({"status": "ok"})
274                    }
275                    Err(e) => json!({"status": "error", "message": e.to_string()}),
276                },
277                Err(e) => json!({"status": "error", "message": e.to_string()}),
278            }
279        }
280
281        other => json!({"status": "error", "message": format!("unknown command: {other}")}),
282    }
283}
284
285fn message_loop() {
286    let mut session: Option<Session> = None;
287    loop {
288        match read_message() {
289            Ok(msg) => {
290                // Echo the request `id` back on the response so the
291                // extension's NativePortClient can route concurrent
292                // requests to the right awaiter (popup + content-script
293                // can interleave on the same port).
294                let id = msg.get("id").cloned();
295                let mut resp = dispatch(&mut session, msg);
296                if let Some(id) = id {
297                    if let Some(obj) = resp.as_object_mut() {
298                        obj.insert("id".to_string(), id);
299                    }
300                }
301                if let Err(e) = write_message(&resp) {
302                    eprintln!("nativehost: write error: {e}");
303                    break;
304                }
305            }
306            Err(e) => {
307                // EOF or broken pipe — browser closed the connection, exit cleanly.
308                let s = e.to_string();
309                if s.contains("failed to fill whole buffer")
310                    || s.contains("unexpected end")
311                    || s.contains("os error")
312                {
313                    break;
314                }
315                eprintln!("nativehost: read error: {e}");
316                break;
317            }
318        }
319    }
320}
321
322/// Launch the tsafe native messaging host.
323///
324/// Chrome Native Messaging uses a binary frame protocol. Switches stdin/stdout
325/// to binary mode on Windows before any I/O, then enters the message loop.
326pub fn run() {
327    // Windows text mode mutates stdin/stdout bytes, so switch both handles
328    // to binary mode before any host I/O.
329    #[cfg(target_os = "windows")]
330    {
331        unsafe extern "C" {
332            fn _setmode(fd: std::ffi::c_int, mode: std::ffi::c_int) -> std::ffi::c_int;
333        }
334
335        unsafe {
336            _setmode(0, 0x8000);
337            _setmode(1, 0x8000);
338        }
339    }
340
341    message_loop();
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use std::io::Cursor;
348
349    use tsafe_core::{profile, vault::Vault};
350
351    fn framed_json(bytes: &[u8]) -> Vec<u8> {
352        let mut v = Vec::with_capacity(4 + bytes.len());
353        v.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
354        v.extend_from_slice(bytes);
355        v
356    }
357
358    #[test]
359    fn read_message_from_accepts_valid_length_prefixed_json() {
360        let payload = br#"{"command":"lock"}"#;
361        let v = read_message_from(Cursor::new(framed_json(payload))).unwrap();
362        assert_eq!(v["command"], "lock");
363    }
364
365    #[test]
366    fn read_message_from_rejects_oversized_length_without_reading_body() {
367        let len = (1_048_576 + 1) as u32;
368        let err = read_message_from(Cursor::new(len.to_le_bytes().to_vec())).unwrap_err();
369        assert!(
370            err.to_string().contains("message too large"),
371            "unexpected: {err}"
372        );
373    }
374
375    #[test]
376    fn read_message_from_errors_on_truncated_body() {
377        let mut buf = framed_json(br"{}");
378        buf.truncate(4 + 1); // length says 2, only 1 byte of body
379        let err = read_message_from(Cursor::new(buf)).unwrap_err();
380        let s = err.to_string().to_lowercase();
381        assert!(
382            s.contains("failed to fill") || s.contains("unexpected end"),
383            "unexpected: {err}"
384        );
385    }
386
387    #[test]
388    fn read_message_from_errors_on_eof_before_length() {
389        let err = read_message_from(Cursor::new([])).unwrap_err();
390        let s = err.to_string().to_lowercase();
391        assert!(
392            s.contains("failed to fill") || s.contains("unexpected end"),
393            "unexpected: {err}"
394        );
395    }
396
397    #[test]
398    fn read_message_from_empty_body_fails_json_parse() {
399        let mut v = vec![0u8; 4];
400        v.copy_from_slice(&0u32.to_le_bytes());
401        let err = read_message_from(Cursor::new(v)).unwrap_err();
402        let s = err.to_string().to_lowercase();
403        assert!(
404            s.contains("eof") || s.contains("expected") || s.contains("parse"),
405            "unexpected: {err}"
406        );
407    }
408
409    #[test]
410    fn parse_json_body_rejects_malformed_json() {
411        let err = parse_json_body(br"{not json").unwrap_err();
412        let msg = err.to_string();
413        assert!(
414            msg.contains("key must be a string") || msg.contains("EOF") || msg.contains("expected"),
415            "unexpected error: {msg}"
416        );
417    }
418
419    #[test]
420    fn parse_json_body_rejects_truncated_payload() {
421        let err = parse_json_body(r#"{"command":"#.as_bytes()).unwrap_err();
422        let lower = err.to_string().to_lowercase();
423        assert!(
424            lower.contains("eof") || lower.contains("unexpected"),
425            "unexpected error: {err}"
426        );
427    }
428
429    #[test]
430    fn parse_json_body_accepts_minimal_object() {
431        let v = parse_json_body(br#"{"command":"lock"}"#).unwrap();
432        assert_eq!(v["command"], "lock");
433    }
434
435    #[test]
436    fn dispatch_unknown_command_returns_error() {
437        let mut session = None;
438        let r = dispatch(&mut session, json!({"command": "not-a-real-command"}));
439        assert_eq!(r["status"], "error");
440        assert!(r["message"]
441            .as_str()
442            .unwrap_or("")
443            .contains("unknown command"));
444    }
445
446    #[test]
447    fn dispatch_unlock_rejects_invalid_profile_name() {
448        let mut session = None;
449        let r = dispatch(
450            &mut session,
451            json!({"command": "unlock", "profile": "evil/../../x", "password": "x"}),
452        );
453        assert_eq!(r["status"], "error");
454        assert_eq!(r["message"], "invalid profile name");
455        assert!(session.is_none());
456    }
457
458    #[test]
459    fn dispatch_unlock_requires_password_field() {
460        let mut session = None;
461        let r = dispatch(
462            &mut session,
463            json!({"command": "unlock", "profile": "default"}),
464        );
465        assert_eq!(r["status"], "error");
466        assert_eq!(r["message"], "missing password");
467    }
468
469    #[test]
470    fn dispatch_list_logins_without_session_returns_expired() {
471        let mut session = None;
472        let r = dispatch(
473            &mut session,
474            json!({"command": "list_logins", "session_token": "nope"}),
475        );
476        assert_eq!(r["status"], "error");
477        assert_eq!(r["message"], "session expired");
478    }
479
480    #[test]
481    fn message_hostname_trims_and_skips_empty() {
482        assert_eq!(message_hostname(&json!({"hostname": "  "})), None);
483        assert_eq!(
484            message_hostname(&json!({"hostname": " example.com "})),
485            Some("example.com")
486        );
487    }
488
489    fn unlock_default_session(dir: &std::path::Path) -> (String, Option<Session>) {
490        temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.to_str().unwrap()), || {
491            let vault_path = profile::vault_path("default");
492            std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
493            let _ = std::fs::remove_file(&vault_path);
494            Vault::create(&vault_path, b"pw").unwrap();
495            let mut session = None;
496            let unlock = dispatch(
497                &mut session,
498                json!({"command": "unlock", "profile": "default", "password": "pw"}),
499            );
500            assert_eq!(unlock["status"], "ok", "{unlock:?}");
501            let token = unlock["session_token"].as_str().unwrap().to_string();
502            (token, session)
503        })
504    }
505
506    #[test]
507    fn dispatch_browser_paths_reject_malformed_hostname() {
508        let dir = tempfile::tempdir().unwrap();
509        let (token, mut session) = unlock_default_session(dir.path());
510        let bad_host = (0..14)
511            .map(|i| format!("l{i}"))
512            .collect::<Vec<_>>()
513            .join(".");
514
515        temp_env::with_var(
516            "TSAFE_VAULT_DIR",
517            Some(dir.path().to_str().unwrap()),
518            || {
519                let r = dispatch(
520                    &mut session,
521                    json!({
522                        "command": "list_logins",
523                        "session_token": token,
524                        "hostname": bad_host,
525                    }),
526                );
527                assert_eq!(r["status"], "error");
528                let msg = r["message"].as_str().unwrap();
529                assert!(
530                    msg.contains("hostname rejected") && msg.contains("too many"),
531                    "unexpected message: {msg}"
532                );
533
534                let r = dispatch(
535                    &mut session,
536                    json!({
537                        "command": "get_login",
538                        "session_token": token,
539                        "hostname": bad_host,
540                        "key": "any",
541                    }),
542                );
543                assert_eq!(r["status"], "error");
544                assert!(r["message"].as_str().unwrap().contains("hostname rejected"));
545
546                let r = dispatch(
547                    &mut session,
548                    json!({
549                        "command": "save_login",
550                        "session_token": token,
551                        "hostname": bad_host,
552                        "key": "k",
553                        "value": "v",
554                    }),
555                );
556                assert_eq!(r["status"], "error");
557                assert!(r["message"].as_str().unwrap().contains("hostname rejected"));
558            },
559        );
560    }
561
562    #[test]
563    fn dispatch_list_logins_returns_phishing_warning_for_lookalike_hostname() {
564        let dir = tempfile::tempdir().unwrap();
565        let (token, mut session) = unlock_default_session(dir.path());
566
567        // Write a browser-profiles.json mapping paypal.com to "default".
568        let profiles_path = dir.path().join("browser-profiles.json");
569        std::fs::write(&profiles_path, r#"{"paypal.com":"default"}"#).unwrap();
570
571        temp_env::with_var(
572            "TSAFE_VAULT_DIR",
573            Some(dir.path().to_str().unwrap()),
574            || {
575                // paypa1.com is 1 edit away from paypal.com → phishing_warning
576                let r = dispatch(
577                    &mut session,
578                    json!({
579                        "command": "list_logins",
580                        "session_token": token,
581                        "hostname": "paypa1.com",
582                    }),
583                );
584                assert_eq!(
585                    r["status"].as_str().unwrap(),
586                    "phishing_warning",
587                    "expected phishing_warning, got: {r:?}"
588                );
589                assert_eq!(r["registered"].as_str().unwrap(), "paypal.com");
590            },
591        );
592    }
593
594    #[test]
595    fn dispatch_browser_paths_require_hostname() {
596        let dir = tempfile::tempdir().unwrap();
597        let (token, mut session) = unlock_default_session(dir.path());
598
599        temp_env::with_var(
600            "TSAFE_VAULT_DIR",
601            Some(dir.path().to_str().unwrap()),
602            || {
603                let r = dispatch(
604                    &mut session,
605                    json!({"command": "list_logins", "session_token": token}),
606                );
607                assert_eq!(r["status"], "error");
608                assert_eq!(r["message"], "missing hostname");
609
610                let r = dispatch(
611                    &mut session,
612                    json!({
613                        "command": "get_login",
614                        "session_token": token,
615                        "key": "any",
616                    }),
617                );
618                assert_eq!(r["status"], "error");
619                assert_eq!(r["message"], "missing hostname");
620
621                let r = dispatch(
622                    &mut session,
623                    json!({
624                        "command": "save_login",
625                        "session_token": token,
626                        "key": "k",
627                        "value": "v",
628                    }),
629                );
630                assert_eq!(r["status"], "error");
631                assert_eq!(r["message"], "missing hostname");
632            },
633        );
634    }
635
636    #[test]
637    fn dispatch_preserves_session_across_messages_for_id_echo_and_continuity() {
638        // Regression for the connectNative migration: under the prior
639        // sendNativeMessage model, every request spawned a fresh process
640        // and Session was lost. The fix is the persistent port + this
641        // host's existing in-process loop. This test verifies the
642        // dispatch contract that makes the persistent port work:
643        //   1. session lives across dispatches on the same `&mut Option<Session>`
644        //   2. the `id` field on the request is echoed back on the response
645        //      (added in the main loop wrapper, NOT in dispatch — but the
646        //       wrapper relies on `dispatch` returning a JSON object so
647        //       insertion can happen; this test pins that invariant)
648        let dir = tempfile::tempdir().unwrap();
649        let (token, mut session) = unlock_default_session(dir.path());
650
651        temp_env::with_var(
652            "TSAFE_VAULT_DIR",
653            Some(dir.path().to_str().unwrap()),
654            || {
655                let profiles_path = dir.path().join("browser-profiles.json");
656                std::fs::write(&profiles_path, r#"{"github.com":"default"}"#).unwrap();
657
658                // First call uses the session from unlock — would have
659                // returned "session expired" under the broken one-shot
660                // model. Under the fix, it succeeds.
661                let resp1 = dispatch(
662                    &mut session,
663                    json!({
664                        "command": "list_logins",
665                        "session_token": token,
666                        "hostname": "github.com",
667                        "id": 42,
668                    }),
669                );
670                assert_eq!(resp1["status"], "ok");
671                // The dispatch return MUST be a JSON object so the main
672                // loop can insert the echoed id. If a future refactor
673                // returns a non-object value, the id-echo wrapper fails
674                // silently and the extension's pending Map never resolves.
675                assert!(
676                    resp1.is_object(),
677                    "dispatch must return a JSON object so the main loop can echo `id`"
678                );
679
680                // Second call on the SAME session — proves continuity.
681                let resp2 = dispatch(
682                    &mut session,
683                    json!({
684                        "command": "list_logins",
685                        "session_token": token,
686                        "hostname": "github.com",
687                        "id": 43,
688                    }),
689                );
690                assert_eq!(
691                    resp2["status"], "ok",
692                    "session must survive dispatch-to-dispatch"
693                );
694            },
695        );
696    }
697
698    #[test]
699    fn browser_reads_append_audit_entries() {
700        let dir = tempfile::tempdir().unwrap();
701        let (token, mut session) = unlock_default_session(dir.path());
702
703        temp_env::with_var(
704            "TSAFE_VAULT_DIR",
705            Some(dir.path().to_str().unwrap()),
706            || {
707                let profiles_path = dir.path().join("browser-profiles.json");
708                std::fs::write(&profiles_path, r#"{"github.com":"default"}"#).unwrap();
709
710                let vault_path = profile::vault_path("default");
711                let mut vault = Vault::open(&vault_path, b"pw").unwrap();
712                vault
713                    .set("github.com/octocat", "secret-value", HashMap::new())
714                    .unwrap();
715                drop(vault);
716
717                let list_resp = dispatch(
718                    &mut session,
719                    json!({
720                        "command": "list_logins",
721                        "session_token": token,
722                        "hostname": "github.com",
723                    }),
724                );
725                assert_eq!(list_resp["status"], "ok");
726
727                let get_resp = dispatch(
728                    &mut session,
729                    json!({
730                        "command": "get_login",
731                        "session_token": token,
732                        "hostname": "github.com",
733                        "key": "github.com/octocat",
734                    }),
735                );
736                assert_eq!(get_resp["status"], "ok");
737
738                let audit_path = profile::audit_log_path("default");
739                let content = std::fs::read_to_string(audit_path).unwrap();
740                assert!(content.contains("\"operation\":\"browser-list\""));
741                assert!(content.contains("\"operation\":\"browser-get\""));
742                assert!(content.contains("\"key\":\"github.com/octocat\""));
743            },
744        );
745    }
746}