1use std::path::{Path, PathBuf};
13
14use anyhow::{bail, Context, Result};
15
16pub const MAX_SOCKET_PATH_LEN: usize = 104;
19
20pub fn runtime_dir() -> Result<PathBuf> {
22 let base = dirs::data_dir().context("could not determine the user data directory")?;
23 Ok(base.join("omni-dev"))
24}
25
26pub fn socket_path() -> Result<PathBuf> {
28 Ok(runtime_dir()?.join("daemon.sock"))
29}
30
31pub fn token_path() -> Result<PathBuf> {
34 Ok(runtime_dir()?.join("bridge.token"))
35}
36
37pub fn worktrees_polling_path() -> Result<PathBuf> {
43 Ok(runtime_dir()?.join("worktrees-polling.json"))
44}
45
46pub fn worktrees_pr_cache_path() -> Result<PathBuf> {
54 Ok(runtime_dir()?.join("worktrees-pr-cache.json"))
55}
56
57pub fn token_path_for_socket(socket: &Path) -> PathBuf {
61 socket.parent().map_or_else(
62 || PathBuf::from("bridge.token"),
63 |dir| dir.join("bridge.token"),
64 )
65}
66
67pub fn log_path_for_socket(socket: &Path) -> PathBuf {
73 socket
74 .parent()
75 .map_or_else(|| PathBuf::from("daemon.log"), |dir| dir.join("daemon.log"))
76}
77
78pub fn sessions_journal_dir_for_socket(socket: &Path) -> PathBuf {
85 socket
86 .parent()
87 .map_or_else(|| PathBuf::from("sessions"), |dir| dir.join("sessions"))
88}
89
90pub fn ensure_dir_0700(dir: &Path) -> Result<()> {
98 let mut builder = std::fs::DirBuilder::new();
99 builder.recursive(true);
100 #[cfg(unix)]
101 {
102 use std::os::unix::fs::DirBuilderExt;
103 builder.mode(0o700);
104 }
105 builder
106 .create(dir)
107 .with_context(|| format!("failed to create directory {}", dir.display()))?;
108 #[cfg(unix)]
109 {
110 use std::os::unix::fs::PermissionsExt;
111 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
112 .with_context(|| format!("failed to set 0700 on {}", dir.display()))?;
113 }
114 Ok(())
115}
116
117pub fn write_file_0600(path: &Path, contents: &[u8]) -> Result<()> {
125 use std::io::Write;
126
127 let mut options = std::fs::OpenOptions::new();
128 options.write(true).create(true).truncate(true);
129 #[cfg(unix)]
130 {
131 use std::os::unix::fs::OpenOptionsExt;
132 options.mode(0o600);
133 }
134 let mut file = options
135 .open(path)
136 .with_context(|| format!("failed to create file {}", path.display()))?;
137 ensure_handle_0600(&file)
138 .with_context(|| format!("failed to set 0600 on {}", path.display()))?;
139 file.write_all(contents)
140 .with_context(|| format!("failed to write file {}", path.display()))?;
141 Ok(())
142}
143
144pub fn ensure_handle_0600(file: &std::fs::File) -> Result<()> {
150 #[cfg(unix)]
151 {
152 use std::os::unix::fs::PermissionsExt;
153 let mode = file
154 .metadata()
155 .context("failed to read file metadata")?
156 .permissions()
157 .mode();
158 if mode & 0o077 != 0 {
159 file.set_permissions(std::fs::Permissions::from_mode(0o600))
160 .context("failed to set 0600 on open file")?;
161 }
162 }
163 #[cfg(not(unix))]
164 {
165 let _ = file;
166 }
167 Ok(())
168}
169
170pub fn ensure_parent_dir_0700(path: &Path) -> Result<()> {
177 if let Some(dir) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
178 if !dir.exists() {
179 ensure_dir_0700(dir)?;
180 }
181 }
182 Ok(())
183}
184
185pub fn create_new_file_0600(path: &Path) -> Result<std::fs::File> {
195 let mut options = std::fs::OpenOptions::new();
196 options.write(true).create_new(true);
197 #[cfg(unix)]
198 {
199 use std::os::unix::fs::OpenOptionsExt;
200 options.mode(0o600);
201 }
202 let file = options
203 .open(path)
204 .with_context(|| format!("failed to exclusively create file {}", path.display()))?;
205 if let Err(err) = ensure_handle_0600(&file) {
206 if let Err(remove_err) = std::fs::remove_file(path) {
212 tracing::debug!(
213 "failed to remove {} after its fchmod failed: {remove_err}",
214 path.display()
215 );
216 }
217 return Err(err).with_context(|| format!("failed to set 0600 on {}", path.display()));
218 }
219 Ok(file)
220}
221
222pub fn is_already_exists_error(err: &anyhow::Error) -> bool {
233 err.chain().any(|cause| {
234 cause
235 .downcast_ref::<std::io::Error>()
236 .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::AlreadyExists)
237 })
238}
239
240#[derive(Debug)]
242pub enum FileLockError {
243 Busy,
248 Io(anyhow::Error),
251}
252
253#[derive(Debug)]
266pub struct FileLock {
267 #[cfg(unix)]
268 #[allow(dead_code)] inner: nix::fcntl::Flock<std::fs::File>,
270 #[cfg(not(unix))]
271 path: PathBuf,
272}
273
274#[cfg(unix)]
277const LOCK_REPLACEMENT_RETRIES: u32 = 3;
278
279#[cfg(unix)]
295pub fn try_lock_file_exclusive(path: &Path) -> std::result::Result<FileLock, FileLockError> {
296 use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
297
298 for _ in 0..LOCK_REPLACEMENT_RETRIES {
299 let mut options = std::fs::OpenOptions::new();
300 options.create(true).write(true).truncate(false).mode(0o600);
301 let file = options
302 .open(path)
303 .with_context(|| format!("failed to open lock file {}", path.display()))
304 .map_err(FileLockError::Io)?;
305 ensure_handle_0600(&file)
306 .with_context(|| format!("failed to set 0600 on {}", path.display()))
307 .map_err(FileLockError::Io)?;
308
309 let locked =
310 match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusiveNonblock) {
311 Ok(locked) => locked,
312 Err((_file, nix::errno::Errno::EWOULDBLOCK)) => return Err(FileLockError::Busy),
313 Err((_file, errno)) => {
314 return Err(FileLockError::Io(anyhow::anyhow!(
315 "failed to lock {}: {errno}",
316 path.display()
317 )))
318 }
319 };
320
321 let locked_meta = locked
322 .metadata()
323 .with_context(|| format!("failed to stat locked handle for {}", path.display()))
324 .map_err(FileLockError::Io)?;
325 match std::fs::metadata(path) {
326 Ok(current_meta)
327 if current_meta.dev() == locked_meta.dev()
328 && current_meta.ino() == locked_meta.ino() =>
329 {
330 return Ok(FileLock { inner: locked });
331 }
332 _ => {}
336 }
337 }
338 Err(FileLockError::Io(anyhow::anyhow!(
339 "gave up acquiring the lock on {} after {LOCK_REPLACEMENT_RETRIES} attempts — the file \
340 kept being replaced out from under us",
341 path.display()
342 )))
343}
344
345#[cfg(not(unix))]
346pub fn try_lock_file_exclusive(path: &Path) -> std::result::Result<FileLock, FileLockError> {
347 match create_new_file_0600(path) {
348 Ok(_) => Ok(FileLock {
349 path: path.to_path_buf(),
350 }),
351 Err(err) if is_already_exists_error(&err) => Err(FileLockError::Busy),
352 Err(err) => Err(FileLockError::Io(err)),
353 }
354}
355
356#[cfg(not(unix))]
357impl Drop for FileLock {
358 fn drop(&mut self) {
359 if let Err(err) = std::fs::remove_file(&self.path) {
364 tracing::debug!("failed to remove lock file {}: {err}", self.path.display());
365 }
366 }
367}
368
369pub(crate) fn try_lock_or_busy(path: &Path, what: &str) -> Result<Option<FileLock>> {
377 match try_lock_file_exclusive(path) {
378 Ok(inner) => Ok(Some(inner)),
379 Err(FileLockError::Busy) => Ok(None),
380 Err(FileLockError::Io(err)) => {
381 Err(err.context(format!("failed to lock {what} at {}", path.display())))
382 }
383 }
384}
385
386pub fn set_file_0600(path: &Path) -> Result<()> {
388 #[cfg(unix)]
389 {
390 use std::os::unix::fs::PermissionsExt;
391 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
392 .with_context(|| format!("failed to set 0600 on {}", path.display()))?;
393 }
394 #[cfg(not(unix))]
395 {
396 let _ = path;
397 }
398 Ok(())
399}
400
401pub fn check_socket_path_len(path: &Path) -> Result<()> {
404 let len = path.as_os_str().len();
405 if len >= MAX_SOCKET_PATH_LEN {
406 bail!(
407 "socket path is {len} bytes, exceeding the {MAX_SOCKET_PATH_LEN}-byte limit: {} — \
408 pass a shorter --socket path",
409 path.display()
410 );
411 }
412 Ok(())
413}
414
415#[cfg(test)]
416#[allow(clippy::unwrap_used, clippy::expect_used)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn default_paths_sit_under_the_runtime_dir() {
422 let rt = runtime_dir().unwrap();
423 assert!(socket_path().unwrap().starts_with(&rt));
424 assert!(token_path().unwrap().starts_with(&rt));
425 let polling = worktrees_polling_path().unwrap();
426 assert!(polling.starts_with(&rt));
427 assert_eq!(polling.file_name().unwrap(), "worktrees-polling.json");
428 }
429
430 #[test]
431 fn token_sits_beside_its_socket() {
432 assert_eq!(
433 token_path_for_socket(Path::new("/tmp/x/daemon.sock")),
434 Path::new("/tmp/x/bridge.token")
435 );
436 assert_eq!(
438 token_path_for_socket(Path::new("daemon.sock")),
439 Path::new("bridge.token")
440 );
441 }
442
443 #[test]
444 fn log_sits_beside_its_socket() {
445 assert_eq!(
446 log_path_for_socket(Path::new("/tmp/x/daemon.sock")),
447 Path::new("/tmp/x/daemon.log")
448 );
449 assert_eq!(
451 log_path_for_socket(Path::new("daemon.sock")),
452 Path::new("daemon.log")
453 );
454 }
455
456 #[test]
457 fn journals_sit_beside_their_socket() {
458 assert_eq!(
459 sessions_journal_dir_for_socket(Path::new("/tmp/x/daemon.sock")),
460 Path::new("/tmp/x/sessions")
461 );
462 assert_eq!(
463 sessions_journal_dir_for_socket(Path::new("daemon.sock")),
464 Path::new("sessions")
465 );
466 let socket = socket_path().unwrap();
468 assert_eq!(
469 sessions_journal_dir_for_socket(&socket),
470 runtime_dir().unwrap().join("sessions")
471 );
472 }
473
474 #[test]
475 fn socket_path_length_guard() {
476 check_socket_path_len(Path::new("/tmp/short.sock")).unwrap();
477 let too_long = PathBuf::from(format!("/{}", "a".repeat(MAX_SOCKET_PATH_LEN)));
478 assert!(check_socket_path_len(&too_long).is_err());
479 }
480
481 #[test]
482 fn write_file_0600_creates_owner_only() {
483 let dir = tempfile::tempdir().unwrap();
484 let file = dir.path().join("fresh.token");
485 write_file_0600(&file, b"secret").unwrap();
486 assert_eq!(std::fs::read(&file).unwrap(), b"secret");
487 #[cfg(unix)]
488 {
489 use std::os::unix::fs::PermissionsExt;
490 assert_eq!(
491 std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
492 0o600
493 );
494 }
495 }
496
497 #[cfg(unix)]
498 #[test]
499 fn write_file_0600_retightens_preexisting_loose_file() {
500 use std::os::unix::fs::PermissionsExt;
501 let dir = tempfile::tempdir().unwrap();
502 let file = dir.path().join("stale.token");
503 std::fs::write(&file, "old-secret-with-longer-content").unwrap();
504 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
505 write_file_0600(&file, b"new").unwrap();
506 assert_eq!(std::fs::read(&file).unwrap(), b"new");
507 assert_eq!(
508 std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
509 0o600
510 );
511 }
512
513 #[test]
514 fn ensure_dir_and_file_perms() {
515 let dir = tempfile::tempdir().unwrap();
516 let sub = dir.path().join("run");
517 ensure_dir_0700(&sub).unwrap();
518 assert!(sub.is_dir());
519 let file = sub.join("k");
520 std::fs::write(&file, "x").unwrap();
521 set_file_0600(&file).unwrap();
522 #[cfg(unix)]
523 {
524 use std::os::unix::fs::PermissionsExt;
525 assert_eq!(
526 std::fs::metadata(&sub).unwrap().permissions().mode() & 0o777,
527 0o700
528 );
529 assert_eq!(
530 std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
531 0o600
532 );
533 }
534 }
535
536 #[test]
537 fn ensure_parent_dir_0700_creates_a_missing_parent() {
538 let dir = tempfile::tempdir().unwrap();
539 let file = dir.path().join("run").join("k");
540 ensure_parent_dir_0700(&file).unwrap();
541 assert!(dir.path().join("run").is_dir());
542 #[cfg(unix)]
543 {
544 use std::os::unix::fs::PermissionsExt;
545 assert_eq!(
546 std::fs::metadata(dir.path().join("run"))
547 .unwrap()
548 .permissions()
549 .mode()
550 & 0o777,
551 0o700
552 );
553 }
554 }
555
556 #[test]
557 fn ensure_parent_dir_0700_is_a_noop_when_the_parent_already_exists() {
558 let dir = tempfile::tempdir().unwrap();
559 let file = dir.path().join("k");
560 ensure_parent_dir_0700(&file).unwrap();
562 assert!(dir.path().is_dir());
563 }
564
565 #[test]
566 fn ensure_parent_dir_0700_is_a_noop_for_a_bare_filename() {
567 ensure_parent_dir_0700(Path::new("bare.txt")).unwrap();
569 }
570
571 #[test]
572 fn is_already_exists_error_detects_the_wrapped_io_kind() {
573 let dir = tempfile::tempdir().unwrap();
574 let path = dir.path().join("collide");
575 create_new_file_0600(&path).unwrap();
576 let err = create_new_file_0600(&path).unwrap_err();
577 assert!(is_already_exists_error(&err), "{err:?}");
578 }
579
580 #[test]
581 fn is_already_exists_error_is_false_for_an_unrelated_failure() {
582 let dir = tempfile::tempdir().unwrap();
583 let path = dir.path().join("no-such-parent").join("child");
586 let err = create_new_file_0600(&path).unwrap_err();
587 assert!(!is_already_exists_error(&err), "{err:?}");
588 }
589}