1use std::os::fd::FromRawFd;
2use std::os::unix::net::UnixListener;
3use std::path::Path;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
6use std::time::Duration;
7
8use crate::config::Config;
9use crate::config::validation::parse_idle_timeout_secs;
10
11mod conn;
12mod handlers;
13mod monitor;
14
15use conn::handle_connection;
16use monitor::{listen_fd, setup_signal_handler};
17
18const MAX_NEGOTIATION_FAILURES: u32 = 5;
21
22const MAX_CONCURRENT: usize = 4;
24
25const MAX_SESSIONS: u32 = 64;
27
28const PING_INTERVAL: Duration = Duration::from_mins(1);
30
31pub(crate) struct SharedState {
33 pub(crate) session_count: AtomicU32,
35 pub(crate) container_name: String,
37 pub(crate) idle_timeout_secs: u64,
39 pub(crate) was_socket_activated: bool,
44}
45
46pub fn run(socket_path: &Path, config: &Config, container_name: &str) -> anyhow::Result<()> {
48 let shutdown = Arc::new(AtomicBool::new(false));
49 setup_signal_handler(&shutdown)?;
50
51 let config = config.clone();
52 let path = socket_path.to_path_buf();
53 let idle_timeout_secs = parse_idle_timeout_secs(&config.lifecycle.idle_timeout);
54
55 let activation_fd = listen_fd();
56 let was_socket_activated = activation_fd.is_some();
57 let listener = match activation_fd {
58 Some(fd) => {
59 #[allow(unsafe_code)]
64 unsafe {
65 UnixListener::from_raw_fd(fd)
66 }
67 }
68 None => {
69 let _ = std::fs::remove_file(&path);
70 UnixListener::bind(&path)?
71 }
72 };
73 listener.set_nonblocking(true)?;
77
78 let state = Arc::new(SharedState {
79 session_count: AtomicU32::new(0),
80 container_name: container_name.to_string(),
81 idle_timeout_secs,
82 was_socket_activated,
83 });
84
85 let mut handles: Vec<std::thread::JoinHandle<()>> = Vec::new();
86
87 loop {
88 if shutdown.load(Ordering::Relaxed) {
89 tracing::info!("podbox: shutdown requested, draining connections...");
90 drop(listener);
91 for h in handles {
92 let _ = h.join();
93 }
94 return Ok(());
95 }
96
97 match listener.accept() {
98 Ok((mut stream, _)) => {
99 stream.set_nonblocking(false)?;
100 handles.retain_mut(|h| !h.is_finished());
101
102 if handles.len() >= MAX_CONCURRENT {
103 tracing::warn!(
104 "dropping connection: {} concurrent clients already in flight",
105 handles.len()
106 );
107 continue;
108 }
109
110 let cfg = config.clone();
111 let state = Arc::clone(&state);
112 let handle = std::thread::spawn(move || {
113 if let Err(e) = handle_connection(&mut stream, &cfg, &state) {
114 tracing::error!("error handling connection: {}", e);
115 }
116 });
117 handles.push(handle);
118 }
119 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
120 std::thread::sleep(Duration::from_millis(200));
121 }
122 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
123 Err(e) => {
124 tracing::error!("socket accept failed: {}", e);
125 break;
126 }
127 }
128 }
129
130 Ok(())
131}
132
133#[cfg(test)]
134mod tests {
135 use super::conn::{has_cap, note_failure};
136 use super::handlers::{validate_host_exec_args, validate_uri};
137 use std::collections::HashSet;
138
139 #[test]
142 fn allows_http_https_mailto() {
143 assert_eq!(
144 validate_uri("https://example.com"),
145 Some("https://example.com".to_string())
146 );
147 assert_eq!(
148 validate_uri("http://example.com"),
149 Some("http://example.com".to_string())
150 );
151 assert_eq!(
152 validate_uri("mailto:user@host"),
153 Some("mailto:user@host".to_string())
154 );
155 }
156
157 #[test]
158 fn refuses_path_traversal() {
159 assert_eq!(validate_uri("/etc/passwd"), None);
160 assert_eq!(validate_uri("../foo"), None);
161 assert_eq!(validate_uri(""), None);
162 }
163
164 #[test]
165 fn refuses_unknown_alphabetic_schemes() {
166 assert_eq!(validate_uri("javascript:alert(1)"), None);
167 assert_eq!(validate_uri("file:///etc/passwd"), None);
168 }
169
170 #[test]
171 fn wraps_bare_domain() {
172 assert_eq!(
173 validate_uri("example.com"),
174 Some("https://example.com".to_string())
175 );
176 }
177
178 #[test]
179 fn trims_whitespace() {
180 assert_eq!(
181 validate_uri(" https://example.com "),
182 Some("https://example.com".to_string())
183 );
184 }
185
186 #[test]
189 fn accepts_plain_args() {
190 assert!(validate_host_exec_args(&["ls".into()]).is_ok());
191 assert!(validate_host_exec_args(&["ls".into(), "-la".into(), "/tmp".into()]).is_ok());
192 assert!(validate_host_exec_args(&["git".into(), "log".into(), "--oneline".into()]).is_ok());
193 }
194
195 #[test]
196 fn rejects_shell_metacharacters() {
197 assert!(validate_host_exec_args(&["echo".into(), "foo;bar".into()]).is_err());
198 assert!(validate_host_exec_args(&["echo".into(), "foo|bar".into()]).is_err());
199 assert!(validate_host_exec_args(&["echo".into(), "foo&bar".into()]).is_err());
200 assert!(validate_host_exec_args(&["echo".into(), "$PATH".into()]).is_err());
201 assert!(validate_host_exec_args(&["echo".into(), "`ls`".into()]).is_err());
202 }
203
204 #[test]
205 fn rejects_redirection_operators() {
206 assert!(validate_host_exec_args(&["cat".into(), "<file".into()]).is_err());
207 assert!(validate_host_exec_args(&["echo".into(), ">file".into()]).is_err());
208 assert!(validate_host_exec_args(&["echo".into(), ">>file".into()]).is_err());
209 }
210
211 #[test]
212 fn rejects_glob_and_brace_chars() {
213 assert!(validate_host_exec_args(&["ls".into(), "*.rs".into()]).is_err());
214 assert!(validate_host_exec_args(&["ls".into(), "file?".into()]).is_err());
215 assert!(validate_host_exec_args(&["ls".into(), "[abc]".into()]).is_err());
216 assert!(validate_host_exec_args(&["echo".into(), "{a,b}".into()]).is_err());
217 }
218
219 #[test]
220 fn rejects_subshell_and_escape_chars() {
221 assert!(validate_host_exec_args(&["echo".into(), "$(whoami)".into()]).is_err());
222 assert!(validate_host_exec_args(&["echo".into(), "line1\nline2".into()]).is_err());
223 }
224
225 #[test]
226 fn rejects_restricted_flag_patterns() {
227 assert!(validate_host_exec_args(&["git".into(), "--exec-path=/tmp".into()]).is_err());
228 assert!(validate_host_exec_args(&["git".into(), "--config=user.name".into()]).is_err());
229 assert!(validate_host_exec_args(&["vim".into(), "--plugin=malicious".into()]).is_err());
230 assert!(validate_host_exec_args(&["python".into(), "--load=malicious".into()]).is_err());
231 assert!(validate_host_exec_args(&["python".into(), "--module=malicious".into()]).is_err());
232 assert!(validate_host_exec_args(&["git".into(), "--remote=evil".into()]).is_err());
233 assert!(
234 validate_host_exec_args(&[
235 "ssh".into(),
236 "-o".into(),
237 "StrictHostKeyChecking=no".into()
238 ])
239 .is_err()
240 );
241 }
242
243 #[test]
244 fn restricted_flag_detection_is_case_insensitive() {
245 assert!(validate_host_exec_args(&["git".into(), "--EXEC-PATH=/tmp".into()]).is_err());
246 assert!(validate_host_exec_args(&["GIT".into(), "--Config=evil".into()]).is_err());
247 }
248
249 #[test]
250 fn does_not_restrict_safe_flags() {
251 assert!(validate_host_exec_args(&["git".into(), "--exec".into()]).is_ok());
252 assert!(
253 validate_host_exec_args(&["git".into(), "--exec-path-is-ok".into()]).is_err(),
254 "--exec-path prefix still blocked"
255 );
256 assert!(validate_host_exec_args(&["ls".into(), "--color=auto".into()]).is_ok());
257 assert!(validate_host_exec_args(&["cargo".into(), "--offline".into()]).is_ok());
258 }
259
260 #[test]
261 fn rejects_empty_args_gracefully() {
262 assert!(
263 validate_host_exec_args(&[String::new()]).is_ok(),
264 "empty string is not a metachar"
265 );
266 }
267
268 #[test]
269 fn ascii_lowercase_only() {
270 assert!(validate_host_exec_args(&["git".into(), "--EXEC-PATH=".into()]).is_err());
271 assert!(
272 validate_host_exec_args(&["git".into(), "--\u{0130}".into()]).is_ok(),
273 "Turkish \u{0130} is non-ASCII"
274 );
275 }
276
277 #[test]
280 fn has_cap_none_negotiated_rejects() {
281 assert!(!has_cap(&None, crate::protocol::CAP_NOTIFY));
282 assert!(!has_cap(&None, crate::protocol::CAP_CLIPBOARD));
283 }
284
285 #[test]
286 fn has_cap_accepts_negotiated() {
287 let caps = HashSet::from(["notify".to_string(), "clipboard".to_string()]);
288 let negotiated = Some(caps);
289 assert!(has_cap(&negotiated, crate::protocol::CAP_NOTIFY));
290 assert!(has_cap(&negotiated, crate::protocol::CAP_CLIPBOARD));
291 assert!(!has_cap(&negotiated, crate::protocol::CAP_XDG_OPEN));
292 assert!(!has_cap(&negotiated, crate::protocol::CAP_HOST_EXEC));
293 }
294
295 fn socket_pair() -> (
298 std::os::unix::net::UnixStream,
299 std::os::unix::net::UnixStream,
300 ) {
301 std::os::unix::net::UnixStream::pair().expect("socketpair")
302 }
303
304 #[test]
305 fn note_failure_is_false_below_threshold() {
306 let (mut server, _client) = socket_pair();
307 let mut failures: u32 = 0;
308 for _ in 0..(super::MAX_NEGOTIATION_FAILURES - 1) {
309 assert!(!note_failure(&mut server, &mut failures, "probe"));
310 }
311 assert_eq!(failures, super::MAX_NEGOTIATION_FAILURES - 1);
312 }
313
314 #[test]
315 fn note_failure_drops_after_threshold() {
316 let (mut server, mut client) = socket_pair();
317 let mut failures: u32 = 0;
318 for _ in 0..(super::MAX_NEGOTIATION_FAILURES - 1) {
319 assert!(!note_failure(&mut server, &mut failures, "probe"));
320 }
321 assert!(note_failure(&mut server, &mut failures, "probe"));
322 assert_eq!(failures, super::MAX_NEGOTIATION_FAILURES);
323
324 use crate::protocol::read_frame;
326 for _ in 0..super::MAX_NEGOTIATION_FAILURES {
327 let bytes = read_frame(&mut client).unwrap().expect("error frame");
328 let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
329 assert!(
330 matches!(msg, crate::protocol::HostMessage::Error { reason } if reason == "probe")
331 );
332 }
333 }
334
335 #[test]
338 fn hello_protocol_mismatch_is_rejected() {
339 use super::handlers::{HelloOutcome, handle_hello};
340 let (mut server, mut client) = socket_pair();
341 let config = crate::config::Config::embedded();
342 let outcome = handle_hello(
343 &mut server,
344 &config.integration,
345 0,
346 crate::protocol::PROTOCOL_VERSION + 1,
347 "test".into(),
348 "test".into(),
349 vec![],
350 )
351 .unwrap();
352 assert!(matches!(outcome, HelloOutcome::Rejected));
353
354 let bytes = crate::protocol::read_frame(&mut client)
356 .unwrap()
357 .expect("shutdown frame");
358 let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
359 assert!(matches!(msg, crate::protocol::HostMessage::Shutdown));
360 }
361
362 #[test]
363 fn hello_accepts_enabled_capabilities() {
364 use super::handlers::{HelloOutcome, handle_hello};
365 let (mut server, mut client) = socket_pair();
366 let mut config = crate::config::Config::embedded();
367 config.integration.notify = true;
368 config.integration.clipboard = true;
369 config.integration.xdg_open = false;
370 let outcome = handle_hello(
371 &mut server,
372 &config.integration,
373 0,
374 crate::protocol::PROTOCOL_VERSION,
375 "test".into(),
376 "test".into(),
377 vec![
378 crate::protocol::CAP_NOTIFY.to_string(),
379 crate::protocol::CAP_XDG_OPEN.to_string(),
380 ],
381 )
382 .unwrap();
383 let HelloOutcome::Accepted(accepted) = outcome else {
384 panic!("expected Accepted");
385 };
386 assert_eq!(accepted, vec![crate::protocol::CAP_NOTIFY]);
387
388 let bytes = crate::protocol::read_frame(&mut client)
389 .unwrap()
390 .expect("hello ack");
391 let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
392 match msg {
393 crate::protocol::HostMessage::HelloAck {
394 accepted, rejected, ..
395 } => {
396 assert_eq!(accepted, vec![crate::protocol::CAP_NOTIFY]);
397 assert_eq!(rejected, vec![crate::protocol::CAP_XDG_OPEN]);
398 }
399 other => panic!("expected HelloAck, got {other:?}"),
400 }
401 }
402}