Skip to main content

greplm_core/
client.rs

1//! Client for the greplm daemon.
2
3#[cfg(unix)]
4pub use unix_impl::Client;
5
6#[cfg(not(unix))]
7pub use stub_impl::Client;
8
9// The daemon is built on Unix domain sockets and is unavailable on other
10// platforms. This stub lets the CLI compile everywhere; `try_connect` always
11// returns `None`, so callers transparently fall back to in-process queries.
12#[cfg(not(unix))]
13mod stub_impl {
14    use std::path::Path;
15
16    use crate::error::{Error, Result};
17    use crate::proto::{Freshness, Request, Response};
18
19    /// A connected client to a running greplm daemon (unsupported on this platform).
20    pub struct Client {
21        _private: (),
22    }
23
24    impl Client {
25        /// Always returns `None`: no daemon is available on this platform.
26        pub fn try_connect(_socket: &Path) -> Option<Client> {
27            None
28        }
29
30        /// Always errors: no daemon is available on this platform.
31        pub fn request(&mut self, _req: &Request) -> Result<Response> {
32            Self::unsupported()
33        }
34
35        /// Always errors: no daemon is available on this platform.
36        pub fn request_fresh(&mut self, _req: &Request, _freshness: Freshness) -> Result<Response> {
37            Self::unsupported()
38        }
39
40        /// Always errors: no daemon is available on this platform.
41        pub fn request_routed(
42            &mut self,
43            _root: &std::path::Path,
44            _req: &Request,
45        ) -> Result<Response> {
46            Self::unsupported()
47        }
48
49        /// Always errors: no daemon is available on this platform.
50        pub fn request_routed_fresh(
51            &mut self,
52            _root: &std::path::Path,
53            _req: &Request,
54            _freshness: Freshness,
55        ) -> Result<Response> {
56            Self::unsupported()
57        }
58
59        fn unsupported() -> Result<Response> {
60            Err(Error::other(
61                "greplm daemon is not supported on this platform",
62            ))
63        }
64    }
65}
66
67#[cfg(unix)]
68mod unix_impl {
69    use std::io::{BufRead, BufReader, Read, Write};
70    use std::os::unix::net::UnixStream;
71    use std::path::Path;
72    use std::time::Duration;
73
74    use crate::error::{Error, Result};
75    use crate::proto::{Freshness, LocalRequest, Request, Response, RoutedRequest};
76
77    /// Default write timeout: a healthy daemon drains a request immediately.
78    const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
79    /// Default read timeout: generous enough for a `strict`-freshness reindex on
80    /// a large tree, but bounded so a wedged daemon can't hang the caller.
81    /// Override with `GREPLM_DAEMON_TIMEOUT_MS`.
82    const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(30);
83    /// Cap on a single daemon response so a malicious or buggy peer can't stream
84    /// unbounded bytes (no newline) and OOM the client. Generous enough for the
85    /// largest legitimate `--exhaustive` result set.
86    const MAX_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
87
88    /// A connected client to a running greplm daemon.
89    pub struct Client {
90        reader: BufReader<UnixStream>,
91        writer: UnixStream,
92    }
93
94    impl Client {
95        /// Connect to a daemon listening on `socket`. Returns `None` if no daemon
96        /// is reachable (so callers can fall back to in-process queries).
97        pub fn try_connect(socket: &Path) -> Option<Client> {
98            let stream = UnixStream::connect(socket).ok()?;
99            // Defense in depth on top of the daemon's 0o600 socket in a 0o700
100            // per-user dir: refuse to talk to a socket owned by another user, so
101            // a squatter can't feed forged results to the agent. If the peer uid
102            // can't be determined, proceed (the file-mode check still applies).
103            if let (Some(peer), me) = (peer_uid(&stream), unsafe { libc::getuid() }) {
104                if peer != me {
105                    tracing::warn!(
106                        "daemon socket {} is owned by uid {peer}, not {me}; ignoring it",
107                        socket.display()
108                    );
109                    return None;
110                }
111            }
112            // Bound both directions so a daemon that accepts the connection but
113            // never replies (deadlocked, mid-reload, or a socket squatter) can't
114            // hang the caller forever. On timeout the round-trip errors and the
115            // caller falls back to the next transport — another daemon, or an
116            // in-process query.
117            let read_timeout = std::env::var("GREPLM_DAEMON_TIMEOUT_MS")
118                .ok()
119                .and_then(|s| s.parse::<u64>().ok())
120                .filter(|&ms| ms > 0)
121                .map(Duration::from_millis)
122                .unwrap_or(DEFAULT_READ_TIMEOUT);
123            let _ = stream.set_read_timeout(Some(read_timeout));
124            let _ = stream.set_write_timeout(Some(WRITE_TIMEOUT));
125            let reader = BufReader::new(stream.try_clone().ok()?);
126            Some(Client {
127                reader,
128                writer: stream,
129            })
130        }
131
132        /// Send a request to a per-project daemon and read the response (with
133        /// default [`Freshness::Lazy`]).
134        pub fn request(&mut self, req: &Request) -> Result<Response> {
135            self.request_fresh(req, Freshness::Lazy)
136        }
137
138        /// Send a request to a per-project daemon at a chosen freshness level.
139        pub fn request_fresh(&mut self, req: &Request, freshness: Freshness) -> Result<Response> {
140            let env = LocalRequest {
141                freshness,
142                req: req.clone(),
143            };
144            self.round_trip(&env)
145        }
146
147        /// Send a request to the global multi-root daemon, addressed to a
148        /// specific project `root` (with default [`Freshness::Lazy`]).
149        pub fn request_routed(&mut self, root: &Path, req: &Request) -> Result<Response> {
150            self.request_routed_fresh(root, req, Freshness::Lazy)
151        }
152
153        /// Send a request to the global multi-root daemon at a chosen freshness.
154        pub fn request_routed_fresh(
155            &mut self,
156            root: &Path,
157            req: &Request,
158            freshness: Freshness,
159        ) -> Result<Response> {
160            let routed = RoutedRequest {
161                root: root.to_path_buf(),
162                freshness,
163                req: req.clone(),
164            };
165            self.round_trip(&routed)
166        }
167
168        fn round_trip<T: serde::Serialize>(&mut self, value: &T) -> Result<Response> {
169            let mut bytes = serde_json::to_vec(value)?;
170            bytes.push(b'\n');
171            self.writer.write_all(&bytes).map_err(Error::PlainIo)?;
172            self.writer.flush().map_err(Error::PlainIo)?;
173            let mut line = String::new();
174            // Bound the response so a peer that streams bytes without a newline
175            // can't grow `line` without limit.
176            let n = (&mut self.reader)
177                .take(MAX_RESPONSE_BYTES)
178                .read_line(&mut line)
179                .map_err(Error::PlainIo)?;
180            if n == 0 {
181                return Err(Error::other("daemon closed connection"));
182            }
183            if n as u64 >= MAX_RESPONSE_BYTES && !line.ends_with('\n') {
184                return Err(Error::other("daemon response too large"));
185            }
186            let resp: Response = serde_json::from_str(line.trim())?;
187            Ok(resp)
188        }
189    }
190
191    /// The uid of the process on the other end of `stream`, or `None` if it
192    /// can't be determined. Uses `SO_PEERCRED` on Linux and `getpeereid`
193    /// elsewhere (macOS/BSD).
194    fn peer_uid(stream: &UnixStream) -> Option<u32> {
195        use std::os::unix::io::AsRawFd;
196        let fd = stream.as_raw_fd();
197        #[cfg(any(target_os = "linux", target_os = "android"))]
198        {
199            let mut cred = libc::ucred {
200                pid: 0,
201                uid: 0,
202                gid: 0,
203            };
204            let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
205            let rc = unsafe {
206                libc::getsockopt(
207                    fd,
208                    libc::SOL_SOCKET,
209                    libc::SO_PEERCRED,
210                    &mut cred as *mut libc::ucred as *mut libc::c_void,
211                    &mut len,
212                )
213            };
214            (rc == 0).then_some(cred.uid)
215        }
216        #[cfg(not(any(target_os = "linux", target_os = "android")))]
217        {
218            let mut uid: libc::uid_t = 0;
219            let mut gid: libc::gid_t = 0;
220            let rc = unsafe { libc::getpeereid(fd, &mut uid, &mut gid) };
221            (rc == 0).then_some(uid)
222        }
223    }
224
225    #[cfg(test)]
226    mod tests {
227        use super::*;
228        use std::os::unix::net::UnixListener;
229
230        // The peer of a same-process connection is us — so the security check
231        // reads the right uid and never false-rejects a same-user daemon.
232        #[test]
233        fn peer_uid_is_self_over_a_socket() {
234            let dir = std::env::temp_dir().join(format!("greplm-peer-{}", std::process::id()));
235            std::fs::create_dir_all(&dir).unwrap();
236            let sock = dir.join("t.sock");
237            let _ = std::fs::remove_file(&sock);
238            let listener = UnixListener::bind(&sock).unwrap();
239            let client = UnixStream::connect(&sock).unwrap();
240            let (server, _) = listener.accept().unwrap();
241            let me = unsafe { libc::getuid() };
242            assert_eq!(peer_uid(&client), Some(me));
243            assert_eq!(peer_uid(&server), Some(me));
244            let _ = std::fs::remove_file(&sock);
245            let _ = std::fs::remove_dir(&dir);
246        }
247    }
248}