Skip to main content

a3s_box_runtime/network/
passt.rs

1//! Passt process management for virtio-net networking.
2//!
3//! Manages the lifecycle of `passt` daemon instances that provide
4//! the virtio-net backend for bridge-mode networking. Each box gets
5//! its own passt process with a dedicated Unix socket.
6
7use a3s_box_core::error::{BoxError, Result};
8use std::net::Ipv4Addr;
9use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
10use std::path::{Path, PathBuf};
11use std::process::{Child, Command};
12use std::time::{Duration, Instant};
13
14const PASST_STARTUP_TIMEOUT: Duration = Duration::from_secs(5);
15const PASST_STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(50);
16const PASST_STARTUP_STABILITY_WINDOW: Duration = Duration::from_millis(250);
17
18/// Manages a passt daemon instance for a single box.
19#[derive(Debug)]
20pub struct PasstManager {
21    /// Path to the passt Unix socket.
22    socket_path: PathBuf,
23    /// Path to passt's guest-side packet capture.
24    pcap_path: PathBuf,
25    /// Child process handle (None if not started).
26    child: Option<Child>,
27    /// PID file path for the passt process.
28    pid_file: PathBuf,
29    /// libkrun endpoint inherited by the shim.
30    net_socket_fd: Option<OwnedFd>,
31    /// Multiplexer endpoint inherited by the shim.
32    net_proxy_fd: Option<OwnedFd>,
33}
34
35impl PasstManager {
36    /// Create a new PasstManager.
37    ///
38    /// The socket and PID file are placed directly in the provided runtime
39    /// socket directory (the same directory that holds the exec/PTY control
40    /// sockets, e.g. `/tmp/a3s-box-sockets/<box_id>`).
41    ///
42    /// This directory MUST be reachable by the user passt runs as. When passt
43    /// is started as root it drops privileges to `nobody`, so the socket
44    /// directory has to be world-traversable — the box's `~/.a3s/boxes/<id>`
45    /// home is mode 0700 for root and would leave passt unable to bind its
46    /// socket, silently breaking all bridge networking and Compose.
47    pub fn new(socket_dir: &Path) -> Self {
48        Self {
49            socket_path: socket_dir.join("passt.sock"),
50            pcap_path: socket_dir.join("passt.pcap"),
51            pid_file: socket_dir.join("passt.pid"),
52            child: None,
53            net_socket_fd: None,
54            net_proxy_fd: None,
55        }
56    }
57
58    /// Get the passt socket path.
59    pub fn socket_path(&self) -> &Path {
60        &self.socket_path
61    }
62
63    /// Get the passt packet capture path.
64    pub fn pcap_path(&self) -> &Path {
65        &self.pcap_path
66    }
67
68    /// Insert a shim-hosted peer switch between libkrun and this passt process.
69    pub fn enable_peer_bridge(&mut self) -> Result<()> {
70        if self.net_socket_fd.is_some() || self.net_proxy_fd.is_some() {
71            return Ok(());
72        }
73        let mut descriptors = [-1; 2];
74        #[cfg(target_os = "linux")]
75        let socket_type = libc::SOCK_STREAM | libc::SOCK_CLOEXEC;
76        #[cfg(not(target_os = "linux"))]
77        let socket_type = libc::SOCK_STREAM;
78        let result =
79            unsafe { libc::socketpair(libc::AF_UNIX, socket_type, 0, descriptors.as_mut_ptr()) };
80        if result != 0 {
81            return Err(BoxError::NetworkError(format!(
82                "failed to create passt bridge socketpair: {}",
83                std::io::Error::last_os_error()
84            )));
85        }
86        // SAFETY: socketpair returned two new, uniquely owned descriptors.
87        self.net_socket_fd = Some(unsafe { OwnedFd::from_raw_fd(descriptors[0]) });
88        self.net_proxy_fd = Some(unsafe { OwnedFd::from_raw_fd(descriptors[1]) });
89        Ok(())
90    }
91
92    pub fn net_socket_fd(&self) -> Option<RawFd> {
93        self.net_socket_fd.as_ref().map(AsRawFd::as_raw_fd)
94    }
95
96    pub fn net_proxy_fd(&self) -> Option<RawFd> {
97        self.net_proxy_fd.as_ref().map(AsRawFd::as_raw_fd)
98    }
99
100    /// Spawn the passt daemon.
101    ///
102    /// Configures passt with:
103    /// - Unix socket mode (no PID namespace)
104    /// - The assigned IP, gateway, prefix length
105    /// - DNS forwarding
106    /// - No DHCP (static IP assignment)
107    /// - Inbound TCP port forwarding for any published ports (`port_map`)
108    pub fn spawn(
109        &mut self,
110        ip: Ipv4Addr,
111        gateway: Ipv4Addr,
112        prefix_len: u8,
113        dns_servers: &[Ipv4Addr],
114        port_map: &[String],
115    ) -> Result<()> {
116        // Ensure parent directory exists.
117        if let Some(parent) = self.socket_path.parent() {
118            std::fs::create_dir_all(parent).map_err(|e| {
119                BoxError::NetworkError(format!(
120                    "failed to create socket directory {}: {}",
121                    parent.display(),
122                    e
123                ))
124            })?;
125
126            // passt drops privileges to `nobody` when launched as root, so the
127            // directory it binds its socket (and writes its PID file) in must be
128            // writable by that user. Widen the directory permissions; the path
129            // is an ephemeral, per-box runtime directory under a world-traversable
130            // base, so this only affects this box's control sockets.
131            #[cfg(unix)]
132            {
133                use std::os::unix::fs::PermissionsExt;
134                if let Err(e) =
135                    std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o777))
136                {
137                    tracing::warn!(
138                        dir = %parent.display(),
139                        error = %e,
140                        "Failed to widen passt socket directory permissions; \
141                         passt may be unable to bind its socket after dropping privileges"
142                    );
143                }
144            }
145        }
146
147        self.remove_launch_artifacts();
148
149        match self.spawn_attempt(ip, gateway, prefix_len, dns_servers, port_map, false) {
150            Ok(()) => Ok(()),
151            Err(initial_error) => {
152                let stderr = self.read_stderr();
153                if !should_retry_passt_as_root(&stderr) {
154                    return Err(initial_error);
155                }
156
157                tracing::warn!(
158                    error = %initial_error,
159                    "passt could not create its unprivileged user namespace; retrying with root as the sandbox identity"
160                );
161                self.cleanup_failed_launch();
162                self.spawn_attempt(ip, gateway, prefix_len, dns_servers, port_map, true)
163            }
164        }
165    }
166
167    fn spawn_attempt(
168        &mut self,
169        ip: Ipv4Addr,
170        gateway: Ipv4Addr,
171        prefix_len: u8,
172        dns_servers: &[Ipv4Addr],
173        port_map: &[String],
174        run_as_root: bool,
175    ) -> Result<()> {
176        self.remove_launch_artifacts();
177
178        let mut cmd = Command::new("passt");
179        if run_as_root {
180            cmd.arg("--runas").arg("0:0");
181        }
182        cmd.arg("--socket")
183            .arg(&self.socket_path)
184            .arg("--pid")
185            .arg(&self.pid_file)
186            .arg("--pcap")
187            .arg(&self.pcap_path)
188            // Run in foreground (we manage the process)
189            .arg("--foreground")
190            // Configure the network
191            .arg("--address")
192            .arg(ip.to_string())
193            .arg("--gateway")
194            .arg(gateway.to_string())
195            .arg("--netmask")
196            .arg(format!("{}", prefix_to_netmask(prefix_len)));
197
198        // Add DNS servers
199        for dns in dns_servers {
200            cmd.arg("--dns").arg(dns.to_string());
201        }
202
203        // Forward published TCP ports into the guest. libkrun discards the
204        // TSI host_port_map once a virtio-net device is attached, so passt is
205        // what actually publishes `-p host:guest` in bridge mode. Auto-assigned
206        // host ports (host_port == 0) cannot be forwarded by passt and are
207        // skipped. passt accepts a comma-separated `host:guest,...` spec.
208        let tcp_specs = passt_tcp_port_specs(port_map);
209        if !tcp_specs.is_empty() {
210            let spec = tcp_specs.join(",");
211            tracing::info!(tcp_ports = %spec, "Configuring passt inbound TCP port forwarding");
212            cmd.arg("--tcp-ports").arg(spec);
213        }
214
215        // Capture passt's stderr to a log file so spawn failures (bad args,
216        // unsupported flags, permission errors after dropping privileges) are
217        // diagnosable instead of silently discarded to /dev/null.
218        cmd.stdout(std::process::Stdio::null());
219        match self
220            .stderr_path()
221            .and_then(|p| std::fs::File::create(p).ok())
222        {
223            Some(file) => {
224                cmd.stderr(std::process::Stdio::from(file));
225            }
226            None => {
227                cmd.stderr(std::process::Stdio::null());
228            }
229        }
230
231        let child = cmd.spawn().map_err(|e| {
232            BoxError::NetworkError(format!(
233                "failed to spawn passt: {} (is passt installed?)",
234                e
235            ))
236        })?;
237
238        tracing::info!(
239            pid = child.id(),
240            socket = %self.socket_path.display(),
241            ip = %ip,
242            gateway = %gateway,
243            run_as_root,
244            "Passt daemon started"
245        );
246
247        self.child = Some(child);
248
249        // A socket can appear before passt finishes creating its namespaces and
250        // seccomp sandbox. Require the process to remain alive briefly after
251        // the socket is visible so a late sandbox failure cannot be mistaken
252        // for a usable network backend.
253        self.wait_for_socket()?;
254
255        Ok(())
256    }
257
258    /// Wait for the passt socket to become available.
259    ///
260    /// Also detects immediate passt exit (e.g. bad args or a permission failure
261    /// after dropping privileges) so the real cause is surfaced instead of a
262    /// misleading 5-second timeout.
263    fn wait_for_socket(&mut self) -> Result<()> {
264        let started_at = Instant::now();
265        let mut socket_seen_at = None;
266        while started_at.elapsed() < PASST_STARTUP_TIMEOUT {
267            if let Some(child) = self.child.as_mut() {
268                if let Ok(Some(status)) = child.try_wait() {
269                    // Early exit — try_wait reaped it, so nothing lingers.
270                    return Err(BoxError::NetworkError(format!(
271                        "passt exited early with {status} during startup{}",
272                        self.stderr_tail()
273                    )));
274                }
275            }
276
277            if self.socket_path.exists() {
278                let seen_at = socket_seen_at.get_or_insert_with(Instant::now);
279                if seen_at.elapsed() >= PASST_STARTUP_STABILITY_WINDOW {
280                    return Ok(());
281                }
282            }
283            std::thread::sleep(PASST_STARTUP_POLL_INTERVAL);
284        }
285
286        // Timed out with passt still alive: kill the child we spawned so it does
287        // not linger holding the published port. `Drop` deliberately leaves a
288        // healthy passt running (detached use), and the pid-file fallback used by
289        // boot-failure cleanup can miss it if passt hasn't written the file yet —
290        // so reap it here directly via the handle we hold.
291        if let Some(mut child) = self.child.take() {
292            let _ = child.kill();
293            let _ = child.wait();
294        }
295
296        Err(BoxError::NetworkError(format!(
297            "passt socket {} did not appear within 5 seconds{}",
298            self.socket_path.display(),
299            self.stderr_tail()
300        )))
301    }
302
303    fn stderr_path(&self) -> Option<PathBuf> {
304        self.socket_path
305            .parent()
306            .map(|parent| parent.join("passt.stderr.log"))
307    }
308
309    fn read_stderr(&self) -> String {
310        self.stderr_path()
311            .and_then(|path| std::fs::read_to_string(path).ok())
312            .unwrap_or_default()
313    }
314
315    fn stderr_tail(&self) -> String {
316        let stderr = self.read_stderr();
317        let mut tail: Vec<&str> = stderr.lines().rev().take(4).collect();
318        tail.reverse();
319        if tail.is_empty() {
320            String::new()
321        } else {
322            format!(" (passt stderr: {})", tail.join("; "))
323        }
324    }
325
326    fn cleanup_failed_launch(&mut self) {
327        if let Some(mut child) = self.child.take() {
328            match child.try_wait() {
329                Ok(Some(_)) => {}
330                Ok(None) | Err(_) => {
331                    let _ = child.kill();
332                    let _ = child.wait();
333                }
334            }
335        }
336        self.remove_launch_artifacts();
337    }
338
339    fn remove_launch_artifacts(&self) {
340        let _ = std::fs::remove_file(&self.socket_path);
341        let _ = std::fs::remove_file(&self.pcap_path);
342        let _ = std::fs::remove_file(&self.pid_file);
343        if let Some(stderr_path) = self.stderr_path() {
344            let _ = std::fs::remove_file(stderr_path);
345        }
346    }
347
348    /// Stop the passt daemon.
349    pub fn stop(&mut self) {
350        if let Some(ref mut child) = self.child {
351            let pid = child.id();
352            if let Err(e) = child.kill() {
353                tracing::warn!(pid, error = %e, "Failed to kill passt process");
354            } else {
355                // Reap the child to avoid zombies
356                let _ = child.wait();
357                tracing::info!(pid, "Passt daemon stopped");
358            }
359        }
360        self.child = None;
361        self.net_socket_fd = None;
362        self.net_proxy_fd = None;
363
364        // Clean up socket and PID file
365        std::fs::remove_file(&self.socket_path).ok();
366        std::fs::remove_file(&self.pcap_path).ok();
367        std::fs::remove_file(&self.pid_file).ok();
368    }
369
370    /// Check if the passt process is still running.
371    pub fn is_running(&mut self) -> bool {
372        match self.child {
373            Some(ref mut child) => child.try_wait().ok().flatten().is_none(),
374            None => false,
375        }
376    }
377}
378
379fn passt_sandbox_was_denied(stderr: &str) -> bool {
380    stderr.contains("Failed to sandbox process")
381        && (stderr.contains("unshare: Operation not permitted")
382            || stderr.contains("Operation not permitted"))
383}
384
385#[cfg(target_os = "linux")]
386fn should_retry_passt_as_root(stderr: &str) -> bool {
387    let launcher_is_root = unsafe { libc::geteuid() == 0 };
388    launcher_is_root && passt_sandbox_was_denied(stderr)
389}
390
391#[cfg(not(target_os = "linux"))]
392fn should_retry_passt_as_root(_stderr: &str) -> bool {
393    false
394}
395
396impl Drop for PasstManager {
397    fn drop(&mut self) {
398        // Intentionally does NOT kill passt. passt must outlive the process that
399        // spawned it: a detached `run -d` returns while the box keeps running,
400        // and the VM (driven by the shim) outlives the CLI. Killing on drop here
401        // is exactly what previously left detached bridge boxes with dead
402        // networking. passt is reaped on box stop/rm via `terminate_passt`, which
403        // uses the PID file as the source of truth.
404    }
405}
406
407/// Terminate a passt daemon by its PID file and remove its socket/PID files.
408///
409/// passt outlives the `PasstManager` that launched it (so detached boxes keep
410/// working after the CLI exits), so box teardown cannot rely on a live handle —
411/// the PID file written into the box's runtime socket directory is authoritative.
412pub fn terminate_passt(socket_dir: &Path) {
413    let pid_file = socket_dir.join("passt.pid");
414    if let Ok(contents) = std::fs::read_to_string(&pid_file) {
415        if let Ok(pid) = contents.trim().parse::<i32>() {
416            // Verify the pid is still passt before signalling: the pid file is a
417            // stale snapshot, so if passt already exited and the kernel recycled
418            // its pid, a bare kill would SIGTERM an unrelated process.
419            if pid > 1 && pid_is_passt(pid) {
420                // SIGTERM; passt exits and is reaped by its (re)parent.
421                #[cfg(unix)]
422                unsafe {
423                    libc::kill(pid, libc::SIGTERM);
424                }
425                tracing::info!(pid, "Terminated passt daemon");
426            }
427        }
428    }
429    let _ = std::fs::remove_file(&pid_file);
430    let _ = std::fs::remove_file(socket_dir.join("passt.sock"));
431    let _ = std::fs::remove_file(socket_dir.join("passt.pcap"));
432}
433
434/// Best-effort check that `pid` is actually a passt process, to avoid SIGTERM-ing
435/// an unrelated process that recycled the pid after passt exited. Reads
436/// `/proc/<pid>/comm`; on any error (pid gone, no `/proc`) returns false so the
437/// stale pid is left alone — a genuinely-dead passt was already reaped by its
438/// reparent.
439#[cfg(target_os = "linux")]
440fn pid_is_passt(pid: i32) -> bool {
441    std::fs::read_to_string(format!("/proc/{pid}/comm"))
442        .map(|comm| passt_process_name_is_known(comm.trim()))
443        .unwrap_or(false)
444}
445
446#[cfg(target_os = "linux")]
447fn passt_process_name_is_known(name: &str) -> bool {
448    // Debian/Ubuntu's passt executable selects its optimized implementation at
449    // startup and re-execs the packaged passt.avx2 binary. Keep this allowlist
450    // explicit so a recycled PID with a merely similar process name is not
451    // signalled during box teardown.
452    matches!(name, "passt" | "passt.avx2")
453}
454
455#[cfg(not(target_os = "linux"))]
456fn pid_is_passt(_pid: i32) -> bool {
457    // No procfs to consult; passt is Linux-only, so this path is unreachable in
458    // practice — preserve the prior kill-by-pid-file behavior.
459    true
460}
461
462impl super::NetworkBackend for PasstManager {
463    fn socket_path(&self) -> &std::path::Path {
464        self.socket_path()
465    }
466
467    fn stop(&mut self) {
468        self.stop();
469    }
470}
471
472/// Convert published ports to passt's inbound TCP forwarding spec entries.
473fn passt_tcp_port_specs(port_map: &[String]) -> Vec<String> {
474    port_map
475        .iter()
476        .filter_map(|m| a3s_box_core::parse_port_mapping(m).ok())
477        .filter(|m| m.host_port != 0)
478        .map(|m| format!("{}:{}", m.host_port, m.guest_port))
479        .collect()
480}
481
482/// Convert a prefix length to a dotted-decimal netmask string.
483fn prefix_to_netmask(prefix: u8) -> Ipv4Addr {
484    if prefix == 0 {
485        return Ipv4Addr::new(0, 0, 0, 0);
486    }
487    let mask = !((1u32 << (32 - prefix)) - 1);
488    Ipv4Addr::from(mask)
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[test]
496    fn test_prefix_to_netmask() {
497        assert_eq!(prefix_to_netmask(24), Ipv4Addr::new(255, 255, 255, 0));
498        assert_eq!(prefix_to_netmask(16), Ipv4Addr::new(255, 255, 0, 0));
499        assert_eq!(prefix_to_netmask(8), Ipv4Addr::new(255, 0, 0, 0));
500        assert_eq!(prefix_to_netmask(32), Ipv4Addr::new(255, 255, 255, 255));
501        assert_eq!(prefix_to_netmask(0), Ipv4Addr::new(0, 0, 0, 0));
502        assert_eq!(prefix_to_netmask(28), Ipv4Addr::new(255, 255, 255, 240));
503    }
504
505    #[test]
506    fn test_passt_tcp_port_specs_skips_invalid_and_auto_assigned_ports() {
507        let specs = passt_tcp_port_specs(&[
508            "8080:80".to_string(),
509            "0:443".to_string(),
510            "not-a-port-map".to_string(),
511            "9000:90/tcp".to_string(),
512        ]);
513
514        assert_eq!(specs, vec!["8080:80", "9000:90"]);
515    }
516
517    #[test]
518    fn passt_root_retry_requires_an_explicit_sandbox_permission_failure() {
519        assert!(passt_sandbox_was_denied(
520            "unshare: Operation not permitted\nFailed to sandbox process, exiting"
521        ));
522        assert!(!passt_sandbox_was_denied(
523            "Failed to bind UNIX domain socket"
524        ));
525        assert!(!passt_sandbox_was_denied(
526            "Failed to sandbox process, exiting"
527        ));
528    }
529
530    #[test]
531    fn test_passt_manager_new() {
532        let dir = tempfile::tempdir().unwrap();
533        let mgr = PasstManager::new(dir.path());
534        assert_eq!(mgr.socket_path(), dir.path().join("passt.sock"));
535        assert_eq!(mgr.pcap_path(), dir.path().join("passt.pcap"));
536    }
537
538    #[cfg(target_os = "linux")]
539    #[test]
540    fn peer_bridge_descriptors_are_close_on_exec_until_the_shim_claims_them() {
541        let dir = tempfile::tempdir().unwrap();
542        let mut manager = PasstManager::new(dir.path());
543
544        manager.enable_peer_bridge().unwrap();
545
546        for descriptor in [manager.net_socket_fd(), manager.net_proxy_fd()] {
547            let descriptor = descriptor.unwrap();
548            let flags = unsafe { libc::fcntl(descriptor, libc::F_GETFD) };
549            assert_ne!(flags, -1);
550            assert_ne!(flags & libc::FD_CLOEXEC, 0);
551        }
552    }
553
554    #[test]
555    fn test_passt_manager_implements_network_backend() {
556        let dir = tempfile::tempdir().unwrap();
557        let mut mgr = PasstManager::new(dir.path());
558        let socket_path = dir.path().join("passt.sock");
559        let backend: &mut dyn crate::network::NetworkBackend = &mut mgr;
560
561        assert_eq!(backend.socket_path(), socket_path.as_path());
562        backend.stop();
563    }
564
565    #[test]
566    fn test_passt_manager_not_running_initially() {
567        let dir = tempfile::tempdir().unwrap();
568        let mut mgr = PasstManager::new(dir.path());
569        assert!(!mgr.is_running());
570    }
571
572    #[test]
573    fn test_passt_manager_stop_when_not_started() {
574        let dir = tempfile::tempdir().unwrap();
575        let mut mgr = PasstManager::new(dir.path());
576        // Should not panic
577        mgr.stop();
578        assert!(!mgr.is_running());
579    }
580
581    #[test]
582    fn test_passt_manager_stop_removes_artifacts_without_child() {
583        let dir = tempfile::tempdir().unwrap();
584        let mut mgr = PasstManager::new(dir.path());
585        std::fs::write(&mgr.socket_path, "socket").unwrap();
586        std::fs::write(&mgr.pcap_path, "pcap").unwrap();
587        std::fs::write(&mgr.pid_file, "123").unwrap();
588
589        mgr.stop();
590
591        assert!(!mgr.socket_path.exists());
592        assert!(!mgr.pcap_path.exists());
593        assert!(!mgr.pid_file.exists());
594    }
595
596    #[cfg(unix)]
597    #[test]
598    fn test_passt_manager_stop_kills_child_and_removes_artifacts() {
599        let dir = tempfile::tempdir().unwrap();
600        let mut mgr = PasstManager::new(dir.path());
601        mgr.child = Some(
602            Command::new("sh")
603                .arg("-c")
604                .arg("sleep 30")
605                .spawn()
606                .unwrap(),
607        );
608        std::fs::write(&mgr.socket_path, "socket").unwrap();
609        std::fs::write(&mgr.pcap_path, "pcap").unwrap();
610        std::fs::write(&mgr.pid_file, "123").unwrap();
611
612        assert!(mgr.is_running());
613        mgr.stop();
614
615        assert!(!mgr.is_running());
616        assert!(!mgr.socket_path.exists());
617        assert!(!mgr.pcap_path.exists());
618        assert!(!mgr.pid_file.exists());
619    }
620
621    #[test]
622    fn test_passt_manager_socket_path() {
623        let dir = tempfile::tempdir().unwrap();
624        let box_dir = dir.path().join("boxes").join("test-box-id");
625        let mgr = PasstManager::new(&box_dir);
626        assert_eq!(mgr.socket_path(), box_dir.join("passt.sock"));
627        assert_eq!(mgr.pcap_path(), box_dir.join("passt.pcap"));
628    }
629
630    #[test]
631    fn test_spawn_returns_directory_creation_error_before_running_passt() {
632        let dir = tempfile::tempdir().unwrap();
633        let socket_dir = dir.path().join("socket-dir-is-file");
634        std::fs::write(&socket_dir, "not a directory").unwrap();
635        let mut mgr = PasstManager::new(&socket_dir);
636
637        let err = mgr
638            .spawn(
639                Ipv4Addr::new(10, 0, 2, 15),
640                Ipv4Addr::new(10, 0, 2, 2),
641                24,
642                &[Ipv4Addr::new(1, 1, 1, 1)],
643                &["8080:80".to_string()],
644            )
645            .unwrap_err();
646
647        assert!(err
648            .to_string()
649            .contains("failed to create socket directory"));
650        assert!(!mgr.is_running());
651    }
652
653    #[test]
654    fn test_wait_for_socket_succeeds_when_socket_exists() {
655        let dir = tempfile::tempdir().unwrap();
656        let mut mgr = PasstManager::new(dir.path());
657        std::fs::write(&mgr.socket_path, "socket").unwrap();
658
659        mgr.wait_for_socket().unwrap();
660    }
661
662    #[cfg(unix)]
663    #[test]
664    fn test_wait_for_socket_reports_early_exit_with_stderr_tail() {
665        let dir = tempfile::tempdir().unwrap();
666        let mut mgr = PasstManager::new(dir.path());
667        std::fs::write(
668            dir.path().join("passt.stderr.log"),
669            "line1\nline2\nline3\nline4\nline5\n",
670        )
671        .unwrap();
672        mgr.child = Some(Command::new("sh").arg("-c").arg("exit 7").spawn().unwrap());
673
674        let err = mgr.wait_for_socket().unwrap_err();
675        let message = err.to_string();
676
677        assert!(message.contains("passt exited early"));
678        assert!(message.contains("line2; line3; line4; line5"));
679        assert!(!mgr.is_running());
680    }
681
682    #[test]
683    fn test_terminate_passt_removes_socket_and_pid_files() {
684        let dir = tempfile::tempdir().unwrap();
685        let socket_path = dir.path().join("passt.sock");
686        let pid_path = dir.path().join("passt.pid");
687        let pcap_path = dir.path().join("passt.pcap");
688
689        // A non-existent PID so the SIGTERM is a harmless no-op (ESRCH).
690        std::fs::write(&socket_path, "fake").unwrap();
691        std::fs::write(&pid_path, "2147483647").unwrap();
692        std::fs::write(&pcap_path, "fake pcap").unwrap();
693
694        terminate_passt(dir.path());
695
696        assert!(!socket_path.exists());
697        assert!(!pid_path.exists());
698        assert!(!pcap_path.exists());
699    }
700
701    #[test]
702    fn test_terminate_passt_removes_artifacts_with_invalid_pid_file() {
703        let dir = tempfile::tempdir().unwrap();
704        let socket_path = dir.path().join("passt.sock");
705        let pid_path = dir.path().join("passt.pid");
706        let pcap_path = dir.path().join("passt.pcap");
707
708        std::fs::write(&socket_path, "fake").unwrap();
709        std::fs::write(&pid_path, "not a pid").unwrap();
710        std::fs::write(&pcap_path, "fake pcap").unwrap();
711
712        terminate_passt(dir.path());
713
714        assert!(!socket_path.exists());
715        assert!(!pid_path.exists());
716        assert!(!pcap_path.exists());
717    }
718
719    #[cfg(target_os = "linux")]
720    #[test]
721    fn test_pid_is_passt_rejects_non_passt_processes() {
722        assert!(!pid_is_passt(std::process::id() as i32));
723        assert!(!pid_is_passt(2_147_483_647));
724    }
725
726    #[cfg(target_os = "linux")]
727    #[test]
728    fn passt_process_name_accepts_the_packaged_simd_variant() {
729        assert!(passt_process_name_is_known("passt"));
730        assert!(passt_process_name_is_known("passt.avx2"));
731        assert!(!passt_process_name_is_known("passt-helper"));
732        assert!(!passt_process_name_is_known("passt.avx2.old"));
733    }
734}