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 ensure_dir_0700(dir: &Path) -> Result<()> {
86 let mut builder = std::fs::DirBuilder::new();
87 builder.recursive(true);
88 #[cfg(unix)]
89 {
90 use std::os::unix::fs::DirBuilderExt;
91 builder.mode(0o700);
92 }
93 builder
94 .create(dir)
95 .with_context(|| format!("failed to create directory {}", dir.display()))?;
96 #[cfg(unix)]
97 {
98 use std::os::unix::fs::PermissionsExt;
99 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
100 .with_context(|| format!("failed to set 0700 on {}", dir.display()))?;
101 }
102 Ok(())
103}
104
105pub fn write_file_0600(path: &Path, contents: &[u8]) -> Result<()> {
113 use std::io::Write;
114
115 let mut options = std::fs::OpenOptions::new();
116 options.write(true).create(true).truncate(true);
117 #[cfg(unix)]
118 {
119 use std::os::unix::fs::OpenOptionsExt;
120 options.mode(0o600);
121 }
122 let mut file = options
123 .open(path)
124 .with_context(|| format!("failed to create file {}", path.display()))?;
125 ensure_handle_0600(&file)
126 .with_context(|| format!("failed to set 0600 on {}", path.display()))?;
127 file.write_all(contents)
128 .with_context(|| format!("failed to write file {}", path.display()))?;
129 Ok(())
130}
131
132pub fn ensure_handle_0600(file: &std::fs::File) -> Result<()> {
138 #[cfg(unix)]
139 {
140 use std::os::unix::fs::PermissionsExt;
141 let mode = file
142 .metadata()
143 .context("failed to read file metadata")?
144 .permissions()
145 .mode();
146 if mode & 0o077 != 0 {
147 file.set_permissions(std::fs::Permissions::from_mode(0o600))
148 .context("failed to set 0600 on open file")?;
149 }
150 }
151 #[cfg(not(unix))]
152 {
153 let _ = file;
154 }
155 Ok(())
156}
157
158pub fn ensure_parent_dir_0700(path: &Path) -> Result<()> {
165 if let Some(dir) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
166 if !dir.exists() {
167 ensure_dir_0700(dir)?;
168 }
169 }
170 Ok(())
171}
172
173pub fn create_new_file_0600(path: &Path) -> Result<std::fs::File> {
183 let mut options = std::fs::OpenOptions::new();
184 options.write(true).create_new(true);
185 #[cfg(unix)]
186 {
187 use std::os::unix::fs::OpenOptionsExt;
188 options.mode(0o600);
189 }
190 let file = options
191 .open(path)
192 .with_context(|| format!("failed to exclusively create file {}", path.display()))?;
193 if let Err(err) = ensure_handle_0600(&file) {
194 if let Err(remove_err) = std::fs::remove_file(path) {
200 tracing::debug!(
201 "failed to remove {} after its fchmod failed: {remove_err}",
202 path.display()
203 );
204 }
205 return Err(err).with_context(|| format!("failed to set 0600 on {}", path.display()));
206 }
207 Ok(file)
208}
209
210pub fn is_already_exists_error(err: &anyhow::Error) -> bool {
221 err.chain().any(|cause| {
222 cause
223 .downcast_ref::<std::io::Error>()
224 .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::AlreadyExists)
225 })
226}
227
228#[derive(Debug)]
230pub enum FileLockError {
231 Busy,
236 Io(anyhow::Error),
239}
240
241#[derive(Debug)]
254pub struct FileLock {
255 #[cfg(unix)]
256 #[allow(dead_code)] inner: nix::fcntl::Flock<std::fs::File>,
258 #[cfg(not(unix))]
259 path: PathBuf,
260}
261
262#[cfg(unix)]
265const LOCK_REPLACEMENT_RETRIES: u32 = 3;
266
267#[cfg(unix)]
283pub fn try_lock_file_exclusive(path: &Path) -> std::result::Result<FileLock, FileLockError> {
284 use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
285
286 for _ in 0..LOCK_REPLACEMENT_RETRIES {
287 let mut options = std::fs::OpenOptions::new();
288 options.create(true).write(true).truncate(false).mode(0o600);
289 let file = options
290 .open(path)
291 .with_context(|| format!("failed to open lock file {}", path.display()))
292 .map_err(FileLockError::Io)?;
293 ensure_handle_0600(&file)
294 .with_context(|| format!("failed to set 0600 on {}", path.display()))
295 .map_err(FileLockError::Io)?;
296
297 let locked =
298 match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusiveNonblock) {
299 Ok(locked) => locked,
300 Err((_file, nix::errno::Errno::EWOULDBLOCK)) => return Err(FileLockError::Busy),
301 Err((_file, errno)) => {
302 return Err(FileLockError::Io(anyhow::anyhow!(
303 "failed to lock {}: {errno}",
304 path.display()
305 )))
306 }
307 };
308
309 let locked_meta = locked
310 .metadata()
311 .with_context(|| format!("failed to stat locked handle for {}", path.display()))
312 .map_err(FileLockError::Io)?;
313 match std::fs::metadata(path) {
314 Ok(current_meta)
315 if current_meta.dev() == locked_meta.dev()
316 && current_meta.ino() == locked_meta.ino() =>
317 {
318 return Ok(FileLock { inner: locked });
319 }
320 _ => {}
324 }
325 }
326 Err(FileLockError::Io(anyhow::anyhow!(
327 "gave up acquiring the lock on {} after {LOCK_REPLACEMENT_RETRIES} attempts — the file \
328 kept being replaced out from under us",
329 path.display()
330 )))
331}
332
333#[cfg(not(unix))]
334pub fn try_lock_file_exclusive(path: &Path) -> std::result::Result<FileLock, FileLockError> {
335 match create_new_file_0600(path) {
336 Ok(_) => Ok(FileLock {
337 path: path.to_path_buf(),
338 }),
339 Err(err) if is_already_exists_error(&err) => Err(FileLockError::Busy),
340 Err(err) => Err(FileLockError::Io(err)),
341 }
342}
343
344#[cfg(not(unix))]
345impl Drop for FileLock {
346 fn drop(&mut self) {
347 if let Err(err) = std::fs::remove_file(&self.path) {
352 tracing::debug!("failed to remove lock file {}: {err}", self.path.display());
353 }
354 }
355}
356
357pub(crate) fn try_lock_or_busy(path: &Path, what: &str) -> Result<Option<FileLock>> {
365 match try_lock_file_exclusive(path) {
366 Ok(inner) => Ok(Some(inner)),
367 Err(FileLockError::Busy) => Ok(None),
368 Err(FileLockError::Io(err)) => {
369 Err(err.context(format!("failed to lock {what} at {}", path.display())))
370 }
371 }
372}
373
374pub fn set_file_0600(path: &Path) -> Result<()> {
376 #[cfg(unix)]
377 {
378 use std::os::unix::fs::PermissionsExt;
379 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
380 .with_context(|| format!("failed to set 0600 on {}", path.display()))?;
381 }
382 #[cfg(not(unix))]
383 {
384 let _ = path;
385 }
386 Ok(())
387}
388
389pub fn check_socket_path_len(path: &Path) -> Result<()> {
392 let len = path.as_os_str().len();
393 if len >= MAX_SOCKET_PATH_LEN {
394 bail!(
395 "socket path is {len} bytes, exceeding the {MAX_SOCKET_PATH_LEN}-byte limit: {} — \
396 pass a shorter --socket path",
397 path.display()
398 );
399 }
400 Ok(())
401}
402
403#[cfg(test)]
404#[allow(clippy::unwrap_used, clippy::expect_used)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn default_paths_sit_under_the_runtime_dir() {
410 let rt = runtime_dir().unwrap();
411 assert!(socket_path().unwrap().starts_with(&rt));
412 assert!(token_path().unwrap().starts_with(&rt));
413 let polling = worktrees_polling_path().unwrap();
414 assert!(polling.starts_with(&rt));
415 assert_eq!(polling.file_name().unwrap(), "worktrees-polling.json");
416 }
417
418 #[test]
419 fn token_sits_beside_its_socket() {
420 assert_eq!(
421 token_path_for_socket(Path::new("/tmp/x/daemon.sock")),
422 Path::new("/tmp/x/bridge.token")
423 );
424 assert_eq!(
426 token_path_for_socket(Path::new("daemon.sock")),
427 Path::new("bridge.token")
428 );
429 }
430
431 #[test]
432 fn log_sits_beside_its_socket() {
433 assert_eq!(
434 log_path_for_socket(Path::new("/tmp/x/daemon.sock")),
435 Path::new("/tmp/x/daemon.log")
436 );
437 assert_eq!(
439 log_path_for_socket(Path::new("daemon.sock")),
440 Path::new("daemon.log")
441 );
442 }
443
444 #[test]
445 fn socket_path_length_guard() {
446 check_socket_path_len(Path::new("/tmp/short.sock")).unwrap();
447 let too_long = PathBuf::from(format!("/{}", "a".repeat(MAX_SOCKET_PATH_LEN)));
448 assert!(check_socket_path_len(&too_long).is_err());
449 }
450
451 #[test]
452 fn write_file_0600_creates_owner_only() {
453 let dir = tempfile::tempdir().unwrap();
454 let file = dir.path().join("fresh.token");
455 write_file_0600(&file, b"secret").unwrap();
456 assert_eq!(std::fs::read(&file).unwrap(), b"secret");
457 #[cfg(unix)]
458 {
459 use std::os::unix::fs::PermissionsExt;
460 assert_eq!(
461 std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
462 0o600
463 );
464 }
465 }
466
467 #[cfg(unix)]
468 #[test]
469 fn write_file_0600_retightens_preexisting_loose_file() {
470 use std::os::unix::fs::PermissionsExt;
471 let dir = tempfile::tempdir().unwrap();
472 let file = dir.path().join("stale.token");
473 std::fs::write(&file, "old-secret-with-longer-content").unwrap();
474 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
475 write_file_0600(&file, b"new").unwrap();
476 assert_eq!(std::fs::read(&file).unwrap(), b"new");
477 assert_eq!(
478 std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
479 0o600
480 );
481 }
482
483 #[test]
484 fn ensure_dir_and_file_perms() {
485 let dir = tempfile::tempdir().unwrap();
486 let sub = dir.path().join("run");
487 ensure_dir_0700(&sub).unwrap();
488 assert!(sub.is_dir());
489 let file = sub.join("k");
490 std::fs::write(&file, "x").unwrap();
491 set_file_0600(&file).unwrap();
492 #[cfg(unix)]
493 {
494 use std::os::unix::fs::PermissionsExt;
495 assert_eq!(
496 std::fs::metadata(&sub).unwrap().permissions().mode() & 0o777,
497 0o700
498 );
499 assert_eq!(
500 std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
501 0o600
502 );
503 }
504 }
505
506 #[test]
507 fn ensure_parent_dir_0700_creates_a_missing_parent() {
508 let dir = tempfile::tempdir().unwrap();
509 let file = dir.path().join("run").join("k");
510 ensure_parent_dir_0700(&file).unwrap();
511 assert!(dir.path().join("run").is_dir());
512 #[cfg(unix)]
513 {
514 use std::os::unix::fs::PermissionsExt;
515 assert_eq!(
516 std::fs::metadata(dir.path().join("run"))
517 .unwrap()
518 .permissions()
519 .mode()
520 & 0o777,
521 0o700
522 );
523 }
524 }
525
526 #[test]
527 fn ensure_parent_dir_0700_is_a_noop_when_the_parent_already_exists() {
528 let dir = tempfile::tempdir().unwrap();
529 let file = dir.path().join("k");
530 ensure_parent_dir_0700(&file).unwrap();
532 assert!(dir.path().is_dir());
533 }
534
535 #[test]
536 fn ensure_parent_dir_0700_is_a_noop_for_a_bare_filename() {
537 ensure_parent_dir_0700(Path::new("bare.txt")).unwrap();
539 }
540
541 #[test]
542 fn is_already_exists_error_detects_the_wrapped_io_kind() {
543 let dir = tempfile::tempdir().unwrap();
544 let path = dir.path().join("collide");
545 create_new_file_0600(&path).unwrap();
546 let err = create_new_file_0600(&path).unwrap_err();
547 assert!(is_already_exists_error(&err), "{err:?}");
548 }
549
550 #[test]
551 fn is_already_exists_error_is_false_for_an_unrelated_failure() {
552 let dir = tempfile::tempdir().unwrap();
553 let path = dir.path().join("no-such-parent").join("child");
556 let err = create_new_file_0600(&path).unwrap_err();
557 assert!(!is_already_exists_error(&err), "{err:?}");
558 }
559}