1#![cfg(unix)]
31
32use std::{
33 path::{Path, PathBuf},
34 sync::{Arc, Mutex},
35};
36
37use api::heddle::api::v1alpha1::state_review_service_server::StateReviewServiceServer;
38use objects::error::{HeddleError, Result};
39use repo::{Repository, operation_dedup::OperationDedupStore};
40use tokio::net::UnixListener;
41use tokio_stream::{StreamExt, wrappers::UnixListenerStream};
42use tonic::transport::Server;
43
44use crate::grpc_local_impl::{GrpcLocalService, LocalStateReviewService};
45
46const PRIVATE_SOCKET_UMASK: libc::mode_t = 0o177;
47
48static SOCKET_BIND_UMASK_LOCK: Mutex<()> = Mutex::new(());
49
50pub fn default_socket_path(heddle_dir: &Path) -> PathBuf {
52 heddle_dir.join("sockets").join("grpc.sock")
53}
54
55pub fn default_pid_path(heddle_dir: &Path) -> PathBuf {
57 heddle_dir.join("sockets").join("grpc.pid")
58}
59
60pub struct LocalDaemonConfig {
63 pub socket_path: PathBuf,
64 pub pid_path: PathBuf,
65}
66
67impl LocalDaemonConfig {
68 pub fn from_repo(repo: &Repository) -> Self {
69 let heddle_dir = repo.heddle_dir();
70 Self {
71 socket_path: default_socket_path(heddle_dir),
72 pid_path: default_pid_path(heddle_dir),
73 }
74 }
75
76 pub fn with_socket(mut self, path: PathBuf) -> Self {
77 self.socket_path = path;
78 self
79 }
80}
81
82struct PidGuard {
85 pid_path: PathBuf,
86 socket_path: PathBuf,
87}
88
89pub const PIDFILE_MARKER: &str = "heddle-agent";
93
94#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct PidFileContents {
110 pub pid: i32,
111 pub started_at_secs: i64,
112}
113
114impl PidFileContents {
115 pub fn render(&self) -> String {
117 format!(
118 "{}\n{}\n{}\n",
119 self.pid, PIDFILE_MARKER, self.started_at_secs
120 )
121 }
122
123 pub fn parse(body: &str) -> Option<Self> {
127 let mut lines = body.lines();
128 let pid = lines.next()?.trim().parse::<i32>().ok()?;
129 let marker = lines.next()?.trim();
130 if marker != PIDFILE_MARKER {
131 return None;
132 }
133 let started_at_secs = lines.next()?.trim().parse::<i64>().ok()?;
134 Some(Self {
135 pid,
136 started_at_secs,
137 })
138 }
139}
140
141impl PidGuard {
142 fn install(pid_path: PathBuf, socket_path: PathBuf) -> Result<Self> {
143 if let Some(parent) = pid_path.parent() {
144 std::fs::create_dir_all(parent)?;
145 }
146 if pid_path.exists() {
153 let raw = std::fs::read_to_string(&pid_path).ok();
154 let parsed = raw.as_deref().and_then(PidFileContents::parse);
155 if let Some(existing) = parsed
156 && pid_alive(existing.pid)
157 && is_heddle_process(existing.pid)
158 {
159 return Err(HeddleError::Conflict(format!(
160 "heddle agent serve already running on this repo (pid {}); \
161 stop it first or remove {} if it's stale",
162 existing.pid,
163 pid_path.display()
164 )));
165 }
166 let _ = std::fs::remove_file(&pid_path);
168 if socket_path.exists() {
169 let _ = std::fs::remove_file(&socket_path);
170 }
171 }
172 let contents = PidFileContents {
174 pid: std::process::id() as i32,
175 started_at_secs: std::time::SystemTime::now()
176 .duration_since(std::time::UNIX_EPOCH)
177 .map(|d| d.as_secs() as i64)
178 .unwrap_or(0),
179 };
180 std::fs::write(&pid_path, contents.render())?;
181 Ok(Self {
182 pid_path,
183 socket_path,
184 })
185 }
186}
187
188impl Drop for PidGuard {
189 fn drop(&mut self) {
190 let _ = std::fs::remove_file(&self.pid_path);
191 let _ = std::fs::remove_file(&self.socket_path);
192 }
193}
194
195#[cfg(any(target_os = "linux", target_os = "macos"))]
196pub fn pid_alive(pid: i32) -> bool {
197 unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
201}
202
203#[cfg(not(any(target_os = "linux", target_os = "macos")))]
204pub fn pid_alive(_pid: i32) -> bool {
205 true
208}
209
210pub fn is_heddle_process(pid: i32) -> bool {
224 process_uid_matches_self(pid) && process_exe_matches_current(pid)
225}
226
227#[cfg(target_os = "linux")]
228fn process_uid_matches_self(pid: i32) -> bool {
229 use std::os::unix::fs::MetadataExt;
230
231 let path = PathBuf::from(format!("/proc/{pid}"));
232 let Ok(metadata) = std::fs::metadata(path) else {
233 return false;
234 };
235 metadata.uid() == unsafe { libc::geteuid() }
237}
238
239#[cfg(target_os = "macos")]
244fn process_uid_matches_self(pid: i32) -> bool {
245 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
246 let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
247 let ret = unsafe {
250 libc::proc_pidinfo(
251 pid,
252 libc::PROC_PIDTBSDINFO,
253 0,
254 &mut info as *mut _ as *mut libc::c_void,
255 size,
256 )
257 };
258 if ret <= 0 || ret < size {
259 return false;
260 }
261 info.pbi_uid == unsafe { libc::geteuid() }
263}
264
265#[cfg(not(any(target_os = "linux", target_os = "macos")))]
270fn process_uid_matches_self(_pid: i32) -> bool {
271 false
272}
273
274fn process_exe_matches_current(pid: i32) -> bool {
275 let Some(process_exe) = process_exe_path(pid) else {
276 return false;
277 };
278 let Ok(current_exe) = std::env::current_exe() else {
279 return false;
280 };
281 executable_identity_matches(&process_exe, ¤t_exe)
282}
283
284fn executable_identity_matches(process_exe: &Path, current_exe: &Path) -> bool {
285 let Ok(process_exe) = process_exe.canonicalize() else {
286 return false;
287 };
288 let Ok(current_exe) = current_exe.canonicalize() else {
289 return false;
290 };
291 process_exe == current_exe
292}
293
294#[cfg(target_os = "linux")]
295fn process_exe_path(pid: i32) -> Option<PathBuf> {
296 std::fs::read_link(format!("/proc/{pid}/exe")).ok()
297}
298
299#[cfg(target_os = "macos")]
300fn process_exe_path(pid: i32) -> Option<PathBuf> {
301 use std::{ffi::OsString, os::unix::ffi::OsStringExt};
302
303 let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
304 let len = unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut _, buf.len() as u32) };
306 if len <= 0 {
307 return None;
308 }
309 Some(PathBuf::from(OsString::from_vec(
310 buf[..len as usize].to_vec(),
311 )))
312}
313
314#[cfg(not(any(target_os = "linux", target_os = "macos")))]
315fn process_exe_path(_pid: i32) -> Option<PathBuf> {
316 None
317}
318
319pub async fn serve(
322 repo: Repository,
323 config: LocalDaemonConfig,
324 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
325) -> Result<()> {
326 create_private_socket_parent(&config.socket_path)?;
327 let _guard = PidGuard::install(config.pid_path.clone(), config.socket_path.clone())?;
329
330 if config.socket_path.exists() {
333 std::fs::remove_file(&config.socket_path)?;
334 }
335 let listener = bind_private_unix_listener(&config.socket_path)?;
336 set_socket_mode_0600(&config.socket_path)?;
341 listener.set_nonblocking(true).map_err(|e| {
342 HeddleError::Io(std::io::Error::new(
343 e.kind(),
344 format!(
345 "UnixListener::set_nonblocking({}): {e}",
346 config.socket_path.display()
347 ),
348 ))
349 })?;
350 let listener = UnixListener::from_std(listener).map_err(|e| {
351 HeddleError::Io(std::io::Error::new(
352 e.kind(),
353 format!(
354 "UnixListener::from_std({}): {e}",
355 config.socket_path.display()
356 ),
357 ))
358 })?;
359
360 let dedup = Arc::new(OperationDedupStore::open(repo.heddle_dir())?);
361 let inner = GrpcLocalService::new(Arc::new(repo), dedup);
362
363 let state_review = StateReviewServiceServer::new(LocalStateReviewService::new(inner.clone()));
364
365 let incoming = UnixListenerStream::new(listener).filter_map(guard_peer_connection);
371
372 Server::builder()
373 .add_service(state_review)
374 .serve_with_incoming_shutdown(incoming, shutdown)
375 .await
376 .map_err(|e| HeddleError::InvalidObject(format!("local daemon transport failed: {e}")))?;
377 Ok(())
378}
379
380fn create_private_socket_parent(socket_path: &Path) -> Result<()> {
381 if let Some(parent) = socket_path.parent() {
382 use std::os::unix::fs::DirBuilderExt;
383 let mut builder = std::fs::DirBuilder::new();
384 builder.recursive(true).mode(0o700);
385 builder.create(parent)?;
386 }
387 Ok(())
388}
389
390fn bind_private_unix_listener(socket_path: &Path) -> Result<std::os::unix::net::UnixListener> {
391 let _lock = SOCKET_BIND_UMASK_LOCK
392 .lock()
393 .map_err(|_| HeddleError::InvalidObject("daemon socket umask lock poisoned".to_string()))?;
394 let _umask = UmaskGuard::set(PRIVATE_SOCKET_UMASK);
402 std::os::unix::net::UnixListener::bind(socket_path).map_err(|e| {
403 HeddleError::Io(std::io::Error::new(
404 e.kind(),
405 format!("UnixListener::bind({}): {e}", socket_path.display()),
406 ))
407 })
408}
409
410struct UmaskGuard {
411 previous: libc::mode_t,
412}
413
414impl UmaskGuard {
415 fn set(mask: libc::mode_t) -> Self {
416 let previous = unsafe { libc::umask(mask) };
420 Self { previous }
421 }
422}
423
424impl Drop for UmaskGuard {
425 fn drop(&mut self) {
426 unsafe {
428 libc::umask(self.previous);
429 }
430 }
431}
432
433#[cfg(unix)]
434fn set_socket_mode_0600(path: &Path) -> Result<()> {
435 use std::os::unix::fs::PermissionsExt;
436 let permissions = std::fs::Permissions::from_mode(0o600);
437 std::fs::set_permissions(path, permissions)?;
438 Ok(())
439}
440
441pub fn check_peer_uid_matches_self(stream: &tokio::net::UnixStream) -> Result<()> {
449 let creds = stream
450 .peer_cred()
451 .map_err(|e| HeddleError::InvalidObject(format!("peer_cred failed: {e}")))?;
452 let our_uid = unsafe { libc::geteuid() };
454 enforce_peer_uid(creds.uid(), our_uid)
455}
456
457fn enforce_peer_uid(peer_uid: u32, our_uid: u32) -> Result<()> {
462 if peer_uid != our_uid {
463 return Err(HeddleError::Conflict(format!(
464 "peer uid {peer_uid} does not match daemon uid {our_uid}"
465 )));
466 }
467 Ok(())
468}
469
470fn guard_peer_connection(
476 conn: std::io::Result<tokio::net::UnixStream>,
477) -> Option<std::io::Result<tokio::net::UnixStream>> {
478 match conn {
479 Ok(stream) => match check_peer_uid_matches_self(&stream) {
480 Ok(()) => Some(Ok(stream)),
481 Err(e) => {
482 tracing::warn!(
483 error = %e,
484 "local-daemon: rejecting connection from peer with mismatched uid"
485 );
486 None
487 }
488 },
489 Err(e) => Some(Err(e)),
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use tempfile::TempDir;
496
497 use super::*;
498
499 #[test]
500 #[serial_test::serial(process_global)]
501 fn default_socket_path_lives_under_heddle_dir() {
502 let temp = TempDir::new().unwrap();
503 let heddle = temp.path().join(".heddle");
504 std::fs::create_dir_all(&heddle).unwrap();
505 let path = default_socket_path(&heddle);
506 assert!(path.starts_with(&heddle));
507 assert!(path.ends_with("grpc.sock"));
508 }
509
510 #[test]
511 #[serial_test::serial(process_global)]
512 fn create_private_socket_parent_creates_new_parent_0700() {
513 use std::os::unix::fs::PermissionsExt;
514
515 let temp = TempDir::new().unwrap();
516 let socket = temp
517 .path()
518 .join(".heddle")
519 .join("sockets")
520 .join("grpc.sock");
521 create_private_socket_parent(&socket).unwrap();
522
523 let mode = std::fs::metadata(socket.parent().unwrap())
524 .unwrap()
525 .permissions()
526 .mode()
527 & 0o777;
528 assert_eq!(mode, 0o700, "new socket parent must be private");
529 }
530
531 #[test]
532 fn bind_private_unix_listener_creates_socket_0600_before_chmod() {
533 const TEST_NAME: &str =
534 "local_daemon::tests::bind_private_unix_listener_creates_socket_0600_before_chmod";
535 if !run_umask_test_in_isolated_process(TEST_NAME) {
536 return;
537 }
538
539 use std::os::unix::fs::PermissionsExt;
540
541 let temp = TempDir::new().unwrap();
542 let socket = temp.path().join("grpc.sock");
543
544 let _listener = match bind_private_unix_listener(&socket) {
545 Ok(listener) => listener,
546 Err(HeddleError::Io(err)) if err.kind() == std::io::ErrorKind::PermissionDenied => {
547 eprintln!(
548 "skipping daemon socket mode test: local Unix listener bind denied: {err}"
549 );
550 return;
551 }
552 Err(err) => panic!("bind private Unix listener: {err}"),
553 };
554
555 let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777;
556 assert_eq!(
557 mode, 0o600,
558 "socket must be born private before set_socket_mode_0600 runs"
559 );
560 }
561
562 #[test]
563 fn bind_private_unix_listener_restores_umask_after_bind_error() {
564 const TEST_NAME: &str =
565 "local_daemon::tests::bind_private_unix_listener_restores_umask_after_bind_error";
566 if !run_umask_test_in_isolated_process(TEST_NAME) {
567 return;
568 }
569
570 let temp = TempDir::new().unwrap();
571 let socket = temp.path().join("missing").join("grpc.sock");
572 let before = current_umask();
573
574 let result = bind_private_unix_listener(&socket);
575
576 let after = current_umask();
577 assert!(result.is_err(), "bind should fail for a missing parent");
578 assert_eq!(after, before, "bind errors must restore the prior umask");
579 }
580
581 fn run_umask_test_in_isolated_process(test_name: &str) -> bool {
588 const CHILD_TEST_ENV: &str = "HEDDLE_DAEMON_UMASK_TEST_CHILD";
589
590 if std::env::var_os(CHILD_TEST_ENV).as_deref() == Some(std::ffi::OsStr::new(test_name)) {
591 return true;
592 }
593
594 let output = std::process::Command::new(
595 std::env::current_exe().expect("resolve daemon unit-test executable"),
596 )
597 .args(["--exact", test_name, "--nocapture"])
598 .env(CHILD_TEST_ENV, test_name)
599 .output()
600 .expect("spawn isolated daemon umask test");
601 let stdout = String::from_utf8_lossy(&output.stdout);
602 let stderr = String::from_utf8_lossy(&output.stderr);
603 assert!(
604 output.status.success() && stdout.contains("1 passed"),
605 "isolated umask test {test_name} did not run successfully\nstdout:\n{stdout}\nstderr:\n{stderr}"
606 );
607 false
608 }
609
610 fn current_umask() -> libc::mode_t {
611 unsafe {
614 let current = libc::umask(0);
615 libc::umask(current);
616 current
617 }
618 }
619
620 #[test]
621 #[serial_test::serial(process_global)]
622 fn pid_guard_writes_and_removes_pidfile() {
623 let temp = TempDir::new().unwrap();
624 let pid = temp.path().join("grpc.pid");
625 let sock = temp.path().join("grpc.sock");
626 let guard = PidGuard::install(pid.clone(), sock.clone()).unwrap();
627 assert!(pid.exists());
628 drop(guard);
629 assert!(!pid.exists());
630 assert!(!sock.exists());
631 }
632
633 #[test]
634 #[serial_test::serial(process_global)]
635 fn pid_guard_refuses_when_live_heddle_process_owns_pidfile() {
636 let temp = TempDir::new().unwrap();
637 let pid = temp.path().join("grpc.pid");
638 let sock = temp.path().join("grpc.sock");
639 let first = PidGuard::install(pid.clone(), sock.clone()).unwrap();
643 let result = PidGuard::install(pid.clone(), sock.clone());
644 assert!(result.is_err(), "expected refusal for live owner");
645 drop(first);
646 }
647
648 #[test]
649 #[serial_test::serial(process_global)]
650 fn pid_guard_sweeps_stale_pidfile_with_dead_pid() {
651 let temp = TempDir::new().unwrap();
652 let pid = temp.path().join("grpc.pid");
653 let sock = temp.path().join("grpc.sock");
654 let stale = PidFileContents {
656 pid: 2_147_483_646,
657 started_at_secs: 0,
658 };
659 std::fs::write(&pid, stale.render()).unwrap();
660 std::fs::write(&sock, "stale").unwrap();
661 let _guard = PidGuard::install(pid.clone(), sock.clone()).unwrap();
662 let raw = std::fs::read_to_string(&pid).unwrap();
664 let parsed = PidFileContents::parse(&raw).expect("guard wrote structured pidfile");
665 assert_eq!(parsed.pid, std::process::id() as i32);
666 assert!(parsed.started_at_secs > 0);
667 }
668
669 #[test]
670 #[serial_test::serial(process_global)]
671 fn pid_guard_sweeps_legacy_unstructured_pidfile() {
672 let temp = TempDir::new().unwrap();
676 let pid = temp.path().join("grpc.pid");
677 let sock = temp.path().join("grpc.sock");
678 std::fs::write(&pid, "12345").unwrap();
679 let _guard = PidGuard::install(pid.clone(), sock.clone()).unwrap();
680 let parsed = PidFileContents::parse(&std::fs::read_to_string(&pid).unwrap()).unwrap();
681 assert_eq!(parsed.pid, std::process::id() as i32);
682 }
683
684 #[test]
685 fn pidfile_contents_round_trip() {
686 let original = PidFileContents {
687 pid: 4321,
688 started_at_secs: 1_700_000_000,
689 };
690 let body = original.render();
691 let parsed = PidFileContents::parse(&body).expect("round-trip");
692 assert_eq!(parsed, original);
693 }
694
695 #[test]
696 fn pidfile_contents_rejects_missing_marker() {
697 let body = "1234\nnot-heddle-agent\n100\n";
700 assert!(PidFileContents::parse(body).is_none());
701 }
702
703 #[test]
704 fn pidfile_contents_rejects_bare_pid() {
705 assert!(PidFileContents::parse("12345").is_none());
708 }
709
710 #[test]
711 fn executable_identity_accepts_same_canonical_path() {
712 let current = std::env::current_exe().unwrap();
713 assert!(executable_identity_matches(¤t, ¤t));
714 }
715
716 #[test]
717 fn executable_identity_rejects_spoofed_heddle_path() {
718 let temp = TempDir::new().unwrap();
719 let spoofed = temp.path().join("contains-heddle").join("heddle-spoof");
720 std::fs::create_dir_all(spoofed.parent().unwrap()).unwrap();
721 std::fs::write(&spoofed, "not the current executable").unwrap();
722
723 let current = std::env::current_exe().unwrap();
724
725 assert!(
726 !executable_identity_matches(&spoofed, ¤t),
727 "a pathname containing heddle must not satisfy executable identity"
728 );
729 }
730
731 #[test]
732 fn is_heddle_process_accepts_self_pid() {
733 assert!(
734 is_heddle_process(std::process::id() as i32),
735 "the current process should resolve to the current executable"
736 );
737 }
738
739 #[test]
740 fn enforce_peer_uid_admits_matching_uid() {
741 assert!(enforce_peer_uid(1000, 1000).is_ok());
744 }
745
746 #[test]
747 fn enforce_peer_uid_rejects_mismatched_uid() {
748 let err = enforce_peer_uid(1001, 1000).unwrap_err();
751 assert!(
752 matches!(err, HeddleError::Conflict(_)),
753 "mismatched peer uid must be a Conflict, got {err:?}"
754 );
755 }
756
757 #[test]
758 fn guard_propagates_listener_io_errors() {
759 let io_err = std::io::Error::other("accept failed");
763 let out = guard_peer_connection(Err(io_err));
764 assert!(matches!(out, Some(Err(_))), "io errors must propagate");
765 }
766
767 #[tokio::test]
768 async fn guard_admits_same_process_peer() {
769 let (peer, _local) = tokio::net::UnixStream::pair().expect("socketpair");
773 let out = guard_peer_connection(Ok(peer));
774 assert!(
775 matches!(out, Some(Ok(_))),
776 "a same-uid peer must be admitted by the gate"
777 );
778 }
779
780 #[tokio::test]
781 async fn check_peer_uid_matches_self_admits_socketpair() {
782 let (peer, _local) = tokio::net::UnixStream::pair().expect("socketpair");
785 assert!(check_peer_uid_matches_self(&peer).is_ok());
786 }
787}