Skip to main content

ssh_agent_switcher/
find.rs

1// Copyright 2025 Julio Merino.
2// All rights reserved.
3//
4// Redistribution and use in source and binary forms, with or without modification, are permitted
5// provided that the following conditions are met:
6//
7// * Redistributions of source code must retain the above copyright notice, this list of conditions
8//   and the following disclaimer.
9// * Redistributions in binary form must reproduce the above copyright notice, this list of
10//   conditions and the following disclaimer in the documentation and/or other materials provided with
11//   the distribution.
12// * Neither the name of ssh-agent-switcher nor the names of its contributors may be used to endorse
13//   or promote products derived from this software without specific prior written permission.
14//
15// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
16// IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
17// FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
18// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
20// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
21// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
22// WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23
24//! Utilities to find the correct SSH agent socket.
25
26use log::{debug, info, trace};
27use std::io::{ErrorKind, Result};
28use std::os::unix::fs::MetadataExt;
29use std::path::Path;
30use std::{fs, path::PathBuf};
31use tokio::net::UnixStream;
32
33/// Syntactic sugar to instantiate an error.
34#[macro_export]
35macro_rules! error {
36    ( $kind:expr, $text:expr ) => {
37        std::io::Error::new($kind, $text)
38    };
39
40    ( $kind:expr, $fmt:literal $(, $args:expr)+ ) => {
41        std::io::Error::new($kind, format!($fmt $(, $args)+))
42    };
43}
44
45/// Attempts to open the socket `path`.
46async fn try_open(path: &Path) -> Result<UnixStream> {
47    let name = path.file_name().expect(
48        "The path comes from joining a directory to one of its entries, so it must have a name",
49    );
50    let name = match name.to_str() {
51        Some(name) => name,
52        None => return Err(error!(ErrorKind::InvalidInput, "Invalid socket path")),
53    };
54
55    let is_pre_openssh_10_1 = name.starts_with("agent.");
56    let is_openssh_10_1 = name.contains(".sshd.");
57    if !is_pre_openssh_10_1 && !is_openssh_10_1 {
58        return Err(error!(
59            ErrorKind::InvalidInput,
60            "Socket name in does not start with 'agent.' or does not contain '.sshd.'"
61        ));
62    }
63
64    let metadata =
65        fs::metadata(path).map_err(|e| error!(e.kind(), "Failed to get metadata: {}", e))?;
66
67    if (metadata.mode() & libc::S_IFSOCK as u32) == 0 {
68        return Err(error!(ErrorKind::InvalidInput, "Path is not a socket"));
69    }
70
71    let socket = UnixStream::connect(&path)
72        .await
73        .map_err(|e| error!(e.kind(), "Cannot connect to socket: {}", e))?;
74
75    Ok(socket)
76}
77
78/// Scans the contents of `dir`, which should point to a session directory created by sshd, looks
79/// for a valid socket, opens it, and returns the connection to the agent.
80///
81/// This tries all possible files in search for a socket and only returns an error if no valid
82/// and alive candidate can be found.
83async fn find_in_subdir(dir: &Path) -> Option<UnixStream> {
84    let entries = match fs::read_dir(dir) {
85        Ok(entries) => entries,
86        Err(e) => {
87            debug!("Failed to read directory entries in {}: {}", dir.display(), e);
88            return None;
89        }
90    };
91
92    let mut candidates = vec![];
93    for entry in entries {
94        let entry = match entry {
95            Ok(entry) => entry,
96            Err(e) => {
97                debug!("Failed to read directory entry in {}: {}", dir.display(), e);
98                continue;
99            }
100        };
101
102        let candidate = entry.path();
103        candidates.push(candidate);
104    }
105
106    // The sorting is unnecessary but it helps with testing certain conditions.
107    candidates.sort();
108
109    for candidate in candidates {
110        let socket = match try_open(&candidate).await {
111            Ok(socket) => socket,
112            Err(e) => {
113                trace!("Ignoring candidate socket {}: {}", candidate.display(), e);
114                continue;
115            }
116        };
117
118        info!("Successfully opened socket at {}", candidate.display());
119        return Some(socket);
120    }
121
122    debug!("No socket in directory {}", dir.display());
123    None
124}
125
126/// Scans the contents of `dir`, which should point to one of the directories where sshd places the
127/// session directories for forwarded agents, looks for a valid connection to an agent, opens the
128/// agent's socket, and returns the connection to the agent.
129async fn try_shared_subdir(dir: &Path, uid: libc::uid_t) -> Result<UnixStream> {
130    // It is tempting to use the *at family of system calls to avoid races when checking for
131    // file metadata before opening the socket... but there is no guarantee that the sshd
132    // instance will be present at all even after we open the socket, so the races don't
133    // matter.  Also note that these checks are not meant to protect us against anything in
134    // terms of security: they are merely to keep things speedy and nice.
135
136    let name = dir.file_name().expect(
137            "The candidate path comes from joining a directory to one of its entries, so it must have a name");
138    let name = match name.to_str() {
139        Some(name) => name,
140        None => return Err(error!(ErrorKind::InvalidInput, "Invalid file name")),
141    };
142
143    if !name.starts_with("ssh-") {
144        return Err(error!(ErrorKind::InvalidInput, "Basename does not start with 'ssh-'"));
145    }
146
147    let metadata = fs::metadata(dir).map_err(|e| error!(e.kind(), "Stat failed: {}", e))?;
148
149    if metadata.uid() != uid {
150        return Err(error!(
151            ErrorKind::InvalidInput,
152            "{} is owned by {}, not the current user {}",
153            dir.display(),
154            metadata.uid(),
155            uid
156        ));
157    }
158
159    match find_in_subdir(dir).await {
160        Some(socket) => Ok(socket),
161        None => Err(error!(ErrorKind::NotFound, "No socket in subdirectory")),
162    }
163}
164
165/// Scans the contents of `dir`, which should point to the directory where sshd places the session
166/// directories for forwarded agents, looks for a valid connection to an agent, opens the agent's
167/// socket, and returns the connection to the agent.
168///
169/// This tries all possible directories in search for a socket and only returns an error if no valid
170/// and alive candidate can be found.
171async fn find_in_shared_dir(dir: &Path, our_uid: libc::uid_t) -> Option<UnixStream> {
172    let entries = match fs::read_dir(dir) {
173        Ok(entries) => entries,
174        Err(e) => {
175            debug!("Failed to read directory entries in {}: {}", dir.display(), e);
176            return None;
177        }
178    };
179
180    let mut subdirs = vec![];
181    for entry in entries {
182        let entry = match entry {
183            Ok(entry) => entry,
184            Err(e) => {
185                debug!("Failed to read directory entry in {}: {}", dir.display(), e);
186                continue;
187            }
188        };
189        let path = entry.path();
190
191        match entry.file_type() {
192            Ok(file_type) if file_type.is_dir() => (),
193            Ok(_file_type) => {
194                trace!("Ignoring {}: not a directory", path.display());
195                continue;
196            }
197            Err(e) => {
198                trace!("Ignoring {}: {}", path.display(), e);
199                continue;
200            }
201        };
202
203        subdirs.push(path);
204    }
205
206    // The sorting is unnecessary but it helps with testing certain conditions.
207    subdirs.sort();
208
209    for subdir in subdirs {
210        let socket = match try_shared_subdir(&subdir, our_uid).await {
211            Ok(socket) => socket,
212            Err(e) => {
213                trace!("Ignoring {}: {}", subdir.display(), e);
214                continue;
215            }
216        };
217
218        return Some(socket);
219    }
220
221    debug!("No socket in directory: {}", dir.display());
222    None
223}
224
225/// Scans the contents of `dirs`, which should point to one or more session directories created
226/// by sshd, looks for a valid socket, opens it, and returns the connection to the agent.
227///
228/// This tries all possible files in search for a socket and only returns an error if no valid
229/// and alive candidate can be found.
230pub(super) async fn find_socket(
231    dirs: &[PathBuf],
232    home: Option<&Path>,
233    uid: libc::uid_t,
234) -> Option<UnixStream> {
235    for dir in dirs {
236        if let Some(home) = home
237            && dir.starts_with(home) {
238                debug!("Looking for an agent socket in {} with HOME naming scheme", dir.display());
239                if let Some(socket) = find_in_subdir(dir).await {
240                    return Some(socket);
241                }
242            }
243
244        debug!("Looking for an agent socket in {} subdirs", dir.display());
245        if let Some(socket) = find_in_shared_dir(dir, uid).await {
246            return Some(socket);
247        }
248    }
249
250    None
251}