Skip to main content

podbox/
socket_host.rs

1use std::collections::HashSet;
2use std::os::fd::{AsFd, FromRawFd, OwnedFd, RawFd};
3use std::os::unix::net::{UnixListener, UnixStream};
4use std::path::Path;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
7use std::time::Duration;
8
9use nix::sys::socket::{getsockopt, sockopt};
10
11use crate::config::Config;
12use crate::config::validation::parse_idle_timeout_secs;
13use crate::process;
14use crate::protocol::{GuestMessage, HostMessage, read_frame, write_frame};
15use crate::systemd;
16
17mod handlers;
18
19/// Max number of concurrent host threads handling guest connections.
20const MAX_CONCURRENT: usize = 4;
21
22/// Max number of tracked terminal sessions (pidfd monitors).
23const MAX_SESSIONS: u32 = 64;
24
25/// How often the host sends a keepalive `Ping` to a connected guest.
26const PING_INTERVAL: Duration = Duration::from_mins(1);
27
28/// Register SIGTERM/SIGINT handlers that set `shutdown`.
29///
30/// The accept loop polls the flag on a 200ms tick, so no SA_RESTART /
31/// EINTR coordination is needed.
32fn setup_signal_handler(shutdown: &Arc<AtomicBool>) -> std::io::Result<()> {
33    for sig in [signal_hook::consts::SIGTERM, signal_hook::consts::SIGINT] {
34        signal_hook::flag::register(sig, Arc::clone(shutdown))?;
35    }
36    Ok(())
37}
38
39/// Shared mutable state between all connections and PID monitor threads.
40struct SharedState {
41    /// Number of active terminal sessions tracked via pidfd.
42    session_count: AtomicU32,
43    /// Container name, for `systemctl stop` on idle timeout.
44    container_name: String,
45    /// Idle timeout in seconds (0 = disabled).
46    idle_timeout_secs: u64,
47    /// Whether this process was launched via systemd socket activation
48    /// (`LISTEN_PID`/`LISTEN_FDS` set). If true, the process may
49    /// self-terminate on idle timeout — systemd will re-spawn it via
50    /// socket activation on the next connection.
51    was_socket_activated: bool,
52}
53
54/// Run the host socket server for a container.
55pub fn run(socket_path: &Path, config: &Config, container_name: &str) -> anyhow::Result<()> {
56    let shutdown = Arc::new(AtomicBool::new(false));
57    setup_signal_handler(&shutdown)?;
58
59    let config = config.clone();
60    let path = socket_path.to_path_buf();
61    let idle_timeout_secs = parse_idle_timeout_secs(&config.lifecycle.idle_timeout);
62
63    let activation_fd = listen_fd();
64    let was_socket_activated = activation_fd.is_some();
65    let listener = match activation_fd {
66        Some(fd) => {
67            // SAFETY: `fd` comes from systemd's `LISTEN_FDS` activation
68            // protocol: the user manager hands over ownership of a valid
69            // listening socket fd, which this process must adopt exactly
70            // once. No safe wrapper exists for externally-sourced fds.
71            #[allow(unsafe_code)]
72            unsafe {
73                UnixListener::from_raw_fd(fd)
74            }
75        }
76        None => {
77            let _ = std::fs::remove_file(&path);
78            UnixListener::bind(&path)?
79        }
80    };
81    // Non-blocking + periodic tick so SIGTERM/SIGINT ends the accept loop
82    // promptly instead of blocking in accept(2) until systemd's
83    // TimeoutStopSec SIGKILL.
84    listener.set_nonblocking(true)?;
85
86    let state = Arc::new(SharedState {
87        session_count: AtomicU32::new(0),
88        container_name: container_name.to_string(),
89        idle_timeout_secs,
90        was_socket_activated,
91    });
92
93    let mut handles: Vec<std::thread::JoinHandle<()>> = Vec::new();
94
95    loop {
96        if shutdown.load(Ordering::Relaxed) {
97            tracing::info!("podbox: shutdown requested, draining connections...");
98            drop(listener);
99            for h in handles {
100                let _ = h.join();
101            }
102            return Ok(());
103        }
104
105        match listener.accept() {
106            Ok((mut stream, _)) => {
107                stream.set_nonblocking(false)?;
108                handles.retain_mut(|h| !h.is_finished());
109
110                if handles.len() >= MAX_CONCURRENT {
111                    tracing::warn!(
112                        "dropping connection: {} concurrent clients already in flight",
113                        handles.len()
114                    );
115                    continue;
116                }
117
118                let cfg = config.clone();
119                let state = Arc::clone(&state);
120                let handle = std::thread::spawn(move || {
121                    if let Err(e) = handle_connection(&mut stream, &cfg, &state) {
122                        tracing::error!("error handling connection: {}", e);
123                    }
124                });
125                handles.push(handle);
126            }
127            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
128                std::thread::sleep(Duration::from_millis(200));
129            }
130            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
131            Err(e) => {
132                tracing::error!("socket accept failed: {}", e);
133                break;
134            }
135        }
136    }
137
138    Ok(())
139}
140
141fn listen_fd() -> Option<RawFd> {
142    let pid = std::env::var("LISTEN_PID").ok()?.parse::<u32>().ok()?;
143    if pid != std::process::id() {
144        return None;
145    }
146    let fds = std::env::var("LISTEN_FDS").ok()?.parse::<u32>().ok()?;
147    if fds == 0 {
148        return None;
149    }
150    Some(3)
151}
152
153fn handle_connection(
154    stream: &mut UnixStream,
155    config: &Config,
156    state: &Arc<SharedState>,
157) -> anyhow::Result<()> {
158    stream.set_read_timeout(Some(PING_INTERVAL))?;
159    let mut last_ping = std::time::Instant::now();
160    // Capabilities accepted for this connection during `Hello`. Privileged
161    // messages are rejected until this is populated, and each is further
162    // gated on the specific capability the admin enabled.
163    let mut negotiated: Option<HashSet<String>> = None;
164    // Consecutive failed negotiations. The connection is dropped (fail-closed)
165    // once this reaches `MAX_NEGOTIATION_FAILURES`.
166    let mut failures: u32 = 0;
167
168    loop {
169        let msg_bytes = match read_frame(stream) {
170            Ok(Some(b)) => b,
171            Ok(None) => return Ok(()),
172            Err(e)
173                if e.kind() == std::io::ErrorKind::WouldBlock
174                    || e.kind() == std::io::ErrorKind::TimedOut =>
175            {
176                if last_ping.elapsed() >= PING_INTERVAL {
177                    if write_frame(stream, &HostMessage::Ping).is_err() {
178                        return Ok(());
179                    }
180                    last_ping = std::time::Instant::now();
181                }
182                continue;
183            }
184            Err(e) => return Err(e.into()),
185        };
186
187        last_ping = std::time::Instant::now();
188        let msg: GuestMessage = match serde_json::from_slice(&msg_bytes) {
189            Ok(m) => m,
190            Err(e) => {
191                tracing::warn!("malformed frame from peer: {e}");
192                if note_failure(stream, &mut failures, "malformed frame") {
193                    return Ok(());
194                }
195                continue;
196            }
197        };
198
199        match msg {
200            GuestMessage::Hello {
201                protocol_version,
202                guest_version,
203                container,
204                capabilities,
205            } => {
206                let outcome = handlers::handle_hello(
207                    stream,
208                    &config.integration,
209                    state.idle_timeout_secs,
210                    protocol_version,
211                    guest_version,
212                    container,
213                    capabilities,
214                )?;
215                let handlers::HelloOutcome::Accepted(accepted) = outcome else {
216                    // Failed negotiation: no capabilities granted, daemon stream
217                    // stays unclaimed. Drop after repeated failures.
218                    if note_failure(stream, &mut failures, "hello rejected") {
219                        return Ok(());
220                    }
221                    continue;
222                };
223                negotiated = Some(accepted.into_iter().collect());
224
225                // Idle shutdown is driven entirely by the guest's own idle
226                // timer (which respects idle_timeout). The host must NOT
227                // probe the daemon immediately on hello: a container that was
228                // just started is, by definition, not yet idle, and stopping
229                // it at hello races `podman enter`, which is trying to spawn
230                // a session into it. An immediate check would kill the box
231                // before the user's shell can start.
232            }
233            GuestMessage::RegisterSession => {
234                // host-CLI-only: the peer must be inside the host user
235                // namespace (i.e. running on the host, not in the container).
236                if !peer_is_in_host_userns(stream) {
237                    tracing::warn!("rejecting RegisterSession from foreign user namespace");
238                    let _ = write_frame(
239                        stream,
240                        &HostMessage::Error {
241                            reason: "register_session is host-only".into(),
242                        },
243                    );
244                    return Ok(());
245                }
246                if state.session_count.load(Ordering::SeqCst) >= MAX_SESSIONS {
247                    tracing::warn!("rejecting RegisterSession: session cap reached");
248                    let _ = write_frame(
249                        stream,
250                        &HostMessage::Error {
251                            reason: "session limit reached".into(),
252                        },
253                    );
254                    return Ok(());
255                }
256                // Receive the pidfd via SCM_RIGHTS
257                let raw_fd = match process::recv_fd(stream) {
258                    Ok(Some(fd)) => fd,
259                    Ok(None) => return Ok(()),
260                    Err(_) => return Ok(()),
261                };
262                let fd = process::adopt_scm_fd(raw_fd);
263                state.session_count.fetch_add(1, Ordering::SeqCst);
264                let s = Arc::clone(state);
265                std::thread::spawn(move || monitor_pidfd(fd, s));
266                // Return immediately — the CLI closes the connection after
267                // sending RegisterSession + pidfd.
268                return Ok(());
269            }
270            GuestMessage::Busy => {
271                if negotiated.is_none() {
272                    if note_failure(stream, &mut failures, "hello required") {
273                        return Ok(());
274                    }
275                }
276            }
277            GuestMessage::IdleTimeout => {
278                if negotiated.is_none() {
279                    if note_failure(stream, &mut failures, "hello required") {
280                        return Ok(());
281                    }
282                    continue;
283                }
284                if state.idle_timeout_secs > 0 {
285                    let name = &state.container_name;
286                    tracing::info!("container '{}' idle — stopping", name);
287                    let _ = systemd::stop_unit(name);
288                    // If socket-activated, self-terminate so the host
289                    // service doesn't sit resident forever.  systemd
290                    // re-spawns it via socket activation on the next
291                    // connection.  Non-systemd (manual bind) must stay
292                    // alive — it has no re-launch mechanism.
293                    if state.was_socket_activated {
294                        std::process::exit(0);
295                    }
296                }
297            }
298            GuestMessage::Notify {
299                summary,
300                body,
301                urgency: _,
302                actions,
303                app_name: _,
304            } => {
305                if !has_cap(&negotiated, crate::protocol::CAP_NOTIFY) {
306                    if note_failure(stream, &mut failures, "capability 'notify' not accepted") {
307                        return Ok(());
308                    }
309                    continue;
310                }
311                handlers::handle_notify(stream, summary, body, actions)?
312            }
313            GuestMessage::XdgOpen { uri } => {
314                if !has_cap(&negotiated, crate::protocol::CAP_XDG_OPEN) {
315                    if note_failure(stream, &mut failures, "capability 'xdg_open' not accepted") {
316                        return Ok(());
317                    }
318                    continue;
319                }
320                handlers::handle_xdg_open(uri)?
321            }
322            GuestMessage::ClipboardSet { text } => {
323                if !has_cap(&negotiated, crate::protocol::CAP_CLIPBOARD) {
324                    if note_failure(stream, &mut failures, "capability 'clipboard' not accepted") {
325                        return Ok(());
326                    }
327                    continue;
328                }
329                handlers::handle_clipboard_set(text)?
330            }
331            GuestMessage::ClipboardGet => {
332                if !has_cap(&negotiated, crate::protocol::CAP_CLIPBOARD) {
333                    if note_failure(stream, &mut failures, "capability 'clipboard' not accepted") {
334                        return Ok(());
335                    }
336                    continue;
337                }
338                handlers::handle_clipboard_get(stream)?
339            }
340            GuestMessage::HostExec { cmd, args } => {
341                if !has_cap(&negotiated, crate::protocol::CAP_HOST_EXEC) {
342                    if note_failure(stream, &mut failures, "capability 'host_exec' not accepted") {
343                        return Ok(());
344                    }
345                    continue;
346                }
347                handlers::handle_host_exec(stream, &config.integration, cmd, args)?
348            }
349        }
350    }
351}
352
353/// Maximum consecutive failed negotiations (bad hello, unauthenticated
354/// privileged message, malformed frame) before the connection is dropped.
355const MAX_NEGOTIATION_FAILURES: u32 = 5;
356
357/// Count a failed negotiation and reply with a typed `Error` frame. Returns
358/// true once the connection should be dropped (fail-closed after
359/// `MAX_NEGOTIATION_FAILURES` failures).
360fn note_failure(stream: &mut UnixStream, failures: &mut u32, reason: &str) -> bool {
361    *failures = failures.saturating_add(1);
362    let _ = write_frame(
363        stream,
364        &HostMessage::Error {
365            reason: reason.to_string(),
366        },
367    );
368    *failures >= MAX_NEGOTIATION_FAILURES
369}
370
371/// True if `negotiated` contains the given capability.
372fn has_cap(negotiated: &Option<HashSet<String>>, cap: &str) -> bool {
373    negotiated
374        .as_ref()
375        .is_some_and(|caps| caps.iter().any(|c| c == cap))
376}
377
378/// Whether the peer of `stream` lives in the host user namespace.
379///
380/// Compares `SO_PEERCRED`'s pid against `/proc/self/ns/user`. The host CLI
381/// runs in the host userns; anything inside the container runs in the
382/// container's private userns (rootless podman), so the inode differs.
383fn peer_is_in_host_userns(stream: &UnixStream) -> bool {
384    let creds = match getsockopt(stream, sockopt::PeerCredentials) {
385        Ok(c) => c,
386        Err(_) => return false,
387    };
388    let self_ns = std::fs::read_link("/proc/self/ns/user").ok();
389    let peer_ns = std::fs::read_link(format!("/proc/{}/ns/user", creds.pid())).ok();
390    match (self_ns, peer_ns) {
391        (Some(a), Some(b)) => a == b,
392        _ => false,
393    }
394}
395
396/// Block until `fd` (a pidfd) becomes readable, then decrement the session
397/// counter.
398fn monitor_pidfd(fd: OwnedFd, state: Arc<SharedState>) {
399    let mut fds = [nix::poll::PollFd::new(
400        fd.as_fd(),
401        nix::poll::PollFlags::POLLIN,
402    )];
403
404    loop {
405        match nix::poll::poll(&mut fds, nix::poll::PollTimeout::NONE) {
406            Ok(_) => break,
407            Err(nix::errno::Errno::EINTR) => {}
408            Err(_) => break,
409        }
410    }
411
412    let _ = state.session_count.fetch_sub(1, Ordering::SeqCst);
413    // Idle shutdown is driven entirely by the guest's own idle timer, so
414    // there is no host-side work to do here.
415}
416
417#[cfg(test)]
418mod tests {
419    use super::handlers::{validate_host_exec_args, validate_uri};
420    use super::{has_cap, note_failure};
421    use std::collections::HashSet;
422
423    // ── validate_uri tests ──
424
425    #[test]
426    fn allows_http_https_mailto() {
427        assert_eq!(
428            validate_uri("https://example.com"),
429            Some("https://example.com".to_string())
430        );
431        assert_eq!(
432            validate_uri("http://example.com"),
433            Some("http://example.com".to_string())
434        );
435        assert_eq!(
436            validate_uri("mailto:user@host"),
437            Some("mailto:user@host".to_string())
438        );
439    }
440
441    #[test]
442    fn refuses_path_traversal() {
443        assert_eq!(validate_uri("/etc/passwd"), None);
444        assert_eq!(validate_uri("../foo"), None);
445        assert_eq!(validate_uri(""), None);
446    }
447
448    #[test]
449    fn refuses_unknown_alphabetic_schemes() {
450        assert_eq!(validate_uri("javascript:alert(1)"), None);
451        assert_eq!(validate_uri("file:///etc/passwd"), None);
452    }
453
454    #[test]
455    fn wraps_bare_domain() {
456        assert_eq!(
457            validate_uri("example.com"),
458            Some("https://example.com".to_string())
459        );
460    }
461
462    #[test]
463    fn trims_whitespace() {
464        assert_eq!(
465            validate_uri("  https://example.com  "),
466            Some("https://example.com".to_string())
467        );
468    }
469
470    // ── validate_host_exec_args tests ──
471
472    #[test]
473    fn accepts_plain_args() {
474        assert!(validate_host_exec_args(&["ls".into()]).is_ok());
475        assert!(validate_host_exec_args(&["ls".into(), "-la".into(), "/tmp".into()]).is_ok());
476        assert!(validate_host_exec_args(&["git".into(), "log".into(), "--oneline".into()]).is_ok());
477    }
478
479    #[test]
480    fn rejects_shell_metacharacters() {
481        assert!(validate_host_exec_args(&["echo".into(), "foo;bar".into()]).is_err());
482        assert!(validate_host_exec_args(&["echo".into(), "foo|bar".into()]).is_err());
483        assert!(validate_host_exec_args(&["echo".into(), "foo&bar".into()]).is_err());
484        assert!(validate_host_exec_args(&["echo".into(), "$PATH".into()]).is_err());
485        assert!(validate_host_exec_args(&["echo".into(), "`ls`".into()]).is_err());
486    }
487
488    #[test]
489    fn rejects_redirection_operators() {
490        assert!(validate_host_exec_args(&["cat".into(), "<file".into()]).is_err());
491        assert!(validate_host_exec_args(&["echo".into(), ">file".into()]).is_err());
492        assert!(validate_host_exec_args(&["echo".into(), ">>file".into()]).is_err());
493    }
494
495    #[test]
496    fn rejects_glob_and_brace_chars() {
497        assert!(validate_host_exec_args(&["ls".into(), "*.rs".into()]).is_err());
498        assert!(validate_host_exec_args(&["ls".into(), "file?".into()]).is_err());
499        assert!(validate_host_exec_args(&["ls".into(), "[abc]".into()]).is_err());
500        assert!(validate_host_exec_args(&["echo".into(), "{a,b}".into()]).is_err());
501    }
502
503    #[test]
504    fn rejects_subshell_and_escape_chars() {
505        assert!(validate_host_exec_args(&["echo".into(), "$(whoami)".into()]).is_err());
506        assert!(validate_host_exec_args(&["echo".into(), "line1\nline2".into()]).is_err());
507    }
508
509    #[test]
510    fn rejects_restricted_flag_patterns() {
511        assert!(validate_host_exec_args(&["git".into(), "--exec-path=/tmp".into()]).is_err());
512        assert!(validate_host_exec_args(&["git".into(), "--config=user.name".into()]).is_err());
513        assert!(validate_host_exec_args(&["vim".into(), "--plugin=malicious".into()]).is_err());
514        assert!(validate_host_exec_args(&["python".into(), "--load=malicious".into()]).is_err());
515        assert!(validate_host_exec_args(&["python".into(), "--module=malicious".into()]).is_err());
516        assert!(validate_host_exec_args(&["git".into(), "--remote=evil".into()]).is_err());
517        assert!(
518            validate_host_exec_args(&[
519                "ssh".into(),
520                "-o".into(),
521                "StrictHostKeyChecking=no".into()
522            ])
523            .is_err()
524        );
525    }
526
527    #[test]
528    fn restricted_flag_detection_is_case_insensitive() {
529        assert!(validate_host_exec_args(&["git".into(), "--EXEC-PATH=/tmp".into()]).is_err());
530        assert!(validate_host_exec_args(&["GIT".into(), "--Config=evil".into()]).is_err());
531    }
532
533    #[test]
534    fn does_not_restrict_safe_flags() {
535        assert!(validate_host_exec_args(&["git".into(), "--exec".into()]).is_ok());
536        assert!(
537            validate_host_exec_args(&["git".into(), "--exec-path-is-ok".into()]).is_err(),
538            "--exec-path prefix still blocked"
539        );
540        assert!(validate_host_exec_args(&["ls".into(), "--color=auto".into()]).is_ok());
541        assert!(validate_host_exec_args(&["cargo".into(), "--offline".into()]).is_ok());
542    }
543
544    #[test]
545    fn rejects_empty_args_gracefully() {
546        assert!(
547            validate_host_exec_args(&[String::new()]).is_ok(),
548            "empty string is not a metachar"
549        );
550    }
551
552    #[test]
553    fn ascii_lowercase_only() {
554        assert!(validate_host_exec_args(&["git".into(), "--EXEC-PATH=".into()]).is_err());
555        assert!(
556            validate_host_exec_args(&["git".into(), "--\u{0130}".into()]).is_ok(),
557            "Turkish \u{0130} is non-ASCII"
558        );
559    }
560
561    // ── has_cap tests ──
562
563    #[test]
564    fn has_cap_none_negotiated_rejects() {
565        assert!(!has_cap(&None, crate::protocol::CAP_NOTIFY));
566        assert!(!has_cap(&None, crate::protocol::CAP_CLIPBOARD));
567    }
568
569    #[test]
570    fn has_cap_accepts_negotiated() {
571        let caps = HashSet::from(["notify".to_string(), "clipboard".to_string()]);
572        let negotiated = Some(caps);
573        assert!(has_cap(&negotiated, crate::protocol::CAP_NOTIFY));
574        assert!(has_cap(&negotiated, crate::protocol::CAP_CLIPBOARD));
575        assert!(!has_cap(&negotiated, crate::protocol::CAP_XDG_OPEN));
576        assert!(!has_cap(&negotiated, crate::protocol::CAP_HOST_EXEC));
577    }
578
579    // ── note_failure tests ──
580
581    fn socket_pair() -> (
582        std::os::unix::net::UnixStream,
583        std::os::unix::net::UnixStream,
584    ) {
585        std::os::unix::net::UnixStream::pair().expect("socketpair")
586    }
587
588    #[test]
589    fn note_failure_is_false_below_threshold() {
590        let (mut server, _client) = socket_pair();
591        let mut failures: u32 = 0;
592        for _ in 0..(super::MAX_NEGOTIATION_FAILURES - 1) {
593            assert!(!note_failure(&mut server, &mut failures, "probe"));
594        }
595        assert_eq!(failures, super::MAX_NEGOTIATION_FAILURES - 1);
596    }
597
598    #[test]
599    fn note_failure_drops_after_threshold() {
600        let (mut server, mut client) = socket_pair();
601        let mut failures: u32 = 0;
602        for _ in 0..(super::MAX_NEGOTIATION_FAILURES - 1) {
603            assert!(!note_failure(&mut server, &mut failures, "probe"));
604        }
605        assert!(note_failure(&mut server, &mut failures, "probe"));
606        assert_eq!(failures, super::MAX_NEGOTIATION_FAILURES);
607
608        // Every failure was answered with a typed Error frame.
609        use crate::protocol::read_frame;
610        for _ in 0..super::MAX_NEGOTIATION_FAILURES {
611            let bytes = read_frame(&mut client).unwrap().expect("error frame");
612            let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
613            assert!(
614                matches!(msg, crate::protocol::HostMessage::Error { reason } if reason == "probe")
615            );
616        }
617    }
618
619    // ── handle_hello tests ──
620
621    #[test]
622    fn hello_protocol_mismatch_is_rejected() {
623        use super::handlers::{HelloOutcome, handle_hello};
624        let (mut server, mut client) = socket_pair();
625        let config = crate::config::Config::embedded();
626        let outcome = handle_hello(
627            &mut server,
628            &config.integration,
629            0,
630            crate::protocol::PROTOCOL_VERSION + 1,
631            "test".into(),
632            "test".into(),
633            vec![],
634        )
635        .unwrap();
636        assert!(matches!(outcome, HelloOutcome::Rejected));
637
638        // The peer is told to shut down, and no capabilities are granted.
639        let bytes = crate::protocol::read_frame(&mut client)
640            .unwrap()
641            .expect("shutdown frame");
642        let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
643        assert!(matches!(msg, crate::protocol::HostMessage::Shutdown));
644    }
645
646    #[test]
647    fn hello_accepts_enabled_capabilities() {
648        use super::handlers::{HelloOutcome, handle_hello};
649        let (mut server, mut client) = socket_pair();
650        let mut config = crate::config::Config::embedded();
651        config.integration.notify = true;
652        config.integration.clipboard = true;
653        config.integration.xdg_open = false;
654        let outcome = handle_hello(
655            &mut server,
656            &config.integration,
657            0,
658            crate::protocol::PROTOCOL_VERSION,
659            "test".into(),
660            "test".into(),
661            vec![
662                crate::protocol::CAP_NOTIFY.to_string(),
663                crate::protocol::CAP_XDG_OPEN.to_string(),
664            ],
665        )
666        .unwrap();
667        let HelloOutcome::Accepted(accepted) = outcome else {
668            panic!("expected Accepted");
669        };
670        assert_eq!(accepted, vec![crate::protocol::CAP_NOTIFY]);
671
672        let bytes = crate::protocol::read_frame(&mut client)
673            .unwrap()
674            .expect("hello ack");
675        let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
676        match msg {
677            crate::protocol::HostMessage::HelloAck {
678                accepted, rejected, ..
679            } => {
680                assert_eq!(accepted, vec![crate::protocol::CAP_NOTIFY]);
681                assert_eq!(rejected, vec![crate::protocol::CAP_XDG_OPEN]);
682            }
683            other => panic!("expected HelloAck, got {other:?}"),
684        }
685    }
686}