1use super::store::{MemoryError, ProjectScope};
29use serde::{Deserialize, Serialize};
30use std::fs;
31use std::path::{Component, Path, PathBuf};
32use std::process::{Command, Stdio};
33use std::time::{Duration, Instant};
34
35const WAIT_TIMEOUT: Duration = Duration::from_secs(5);
36const POLL_INTERVAL: Duration = Duration::from_millis(10);
37const MARKER: &str = "synaps-memory-identity.json";
38const LOCK: &str = "synaps-memory-identity.lock";
39const MAX_BYTES: u64 = 1024 * 1024;
40const MAX_ROOTS: usize = 4096;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct RepositoryIdentity {
44 pub scope: ProjectScope,
46 pub legacy_scope: ProjectScope,
48 pub aliases: Vec<ProjectScope>,
52 pub repository_root: PathBuf,
54 pub worktree_root: PathBuf,
56 pub common_dir: Option<PathBuf>,
57}
58
59impl ProjectScope {
60 pub fn discover_repository(start: &Path) -> Result<RepositoryIdentity, MemoryError> {
64 let root = std::env::var_os("SYNAPS_PROJECT_ROOT").map(PathBuf::from);
65 Self::discover_repository_with_override(start, root.as_deref())
66 }
67
68 pub fn discover_repository_with_override(
71 start: &Path,
72 override_root: Option<&Path>,
73 ) -> Result<RepositoryIdentity, MemoryError> {
74 let legacy = Self::discover_with_override(start, override_root)?;
75 Self::validate_repository_key(legacy.key())?;
76 let root = legacy.root().to_path_buf();
77 let has_git = match fs::symlink_metadata(root.join(".git")) {
78 Ok(_) => true,
79 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
80 Err(e) => return Err(e.into()),
81 };
82 if override_root.is_some() || !has_git {
83 return Ok(RepositoryIdentity {
84 scope: legacy.clone(),
85 legacy_scope: legacy.clone(),
86 aliases: vec![legacy],
87 repository_root: root.clone(),
88 worktree_root: root,
89 common_dir: None,
90 });
91 }
92 resolve(legacy)
93 }
94}
95
96fn invalid(message: impl Into<String>) -> MemoryError {
97 MemoryError::InvalidProjectRoot(message.into())
98}
99
100#[cfg(not(unix))]
104fn git(_: &Path, _: &[&str]) -> Result<Vec<u8>, MemoryError> {
105 Err(invalid(
106 "bounded Git discovery requires Unix nonblocking pipes",
107 ))
108}
109
110#[cfg(unix)]
111fn git(root: &Path, args: &[&str]) -> Result<Vec<u8>, MemoryError> {
112 use std::io::{ErrorKind, Read};
113 use std::os::fd::AsRawFd;
114 let mut cmd = Command::new("git");
115 for (name, _) in std::env::vars_os() {
116 if name.as_encoded_bytes().starts_with(b"GIT_") {
117 cmd.env_remove(name);
118 }
119 }
120 cmd.env("GIT_CONFIG_NOSYSTEM", "1")
121 .env("GIT_CONFIG_GLOBAL", "/dev/null")
122 .env("GIT_TERMINAL_PROMPT", "0")
123 .arg("--no-optional-locks")
124 .arg("-C")
125 .arg(root)
126 .args(args)
127 .stdin(Stdio::null())
128 .stdout(Stdio::piped())
129 .stderr(Stdio::null());
130 let deadline = Instant::now() + WAIT_TIMEOUT;
131 let mut child = cmd.spawn()?;
132 let mut stdout = child.stdout.take().unwrap();
133 let result = (|| {
136 let fd = stdout.as_raw_fd();
137 let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
138 if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
139 return Err(std::io::Error::last_os_error().into());
140 }
141 let mut bytes = Vec::new();
142 let mut buffer = [0; 8192];
143 let mut eof = false;
144 loop {
145 if Instant::now() >= deadline {
146 return Err(invalid("Git discovery timed out after 5 seconds"));
147 }
148 let mut received = false;
149 if !eof {
150 match stdout.read(&mut buffer) {
151 Ok(0) => eof = true,
152 Ok(n) => {
153 if bytes.len() + n > MAX_BYTES as usize {
154 return Err(invalid("Git discovery output too large"));
155 }
156 bytes.extend_from_slice(&buffer[..n]);
157 received = true;
158 }
159 Err(e) if e.kind() == ErrorKind::WouldBlock => {}
160 Err(e) if e.kind() == ErrorKind::Interrupted => continue,
161 Err(e) => return Err(e.into()),
162 }
163 }
164 if let Some(status) = child.try_wait()? {
165 if !status.success() {
166 return Err(invalid("Git repository verification failed"));
167 }
168 if eof {
169 return Ok(bytes);
170 }
171 }
172 if !received {
173 std::thread::sleep(
174 POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now())),
175 );
176 }
177 }
178 })();
179 if result.is_err() {
180 let _ = child.kill();
183 let _ = child.wait();
184 }
185 result
186}
187
188fn path_bytes(bytes: &[u8]) -> Result<PathBuf, MemoryError> {
189 #[cfg(unix)]
190 {
191 use std::os::unix::ffi::OsStrExt;
192 Ok(PathBuf::from(std::ffi::OsStr::from_bytes(bytes)))
193 }
194 #[cfg(not(unix))]
195 Ok(PathBuf::from(
196 std::str::from_utf8(bytes).map_err(|_| invalid("non-UTF8 Git path"))?,
197 ))
198}
199
200fn git_path(root: &Path, arg: &str) -> Result<PathBuf, MemoryError> {
201 let bytes = git(root, &["rev-parse", "--path-format=absolute", arg])?;
202 let bytes = bytes
203 .strip_suffix(b"\n")
204 .ok_or_else(|| invalid("invalid Git path output"))?;
205 let path = path_bytes(bytes)?;
206 if !path.is_absolute() {
207 return Err(invalid("Git returned a relative path"));
208 }
209 Ok(path.canonicalize()?)
210}
211
212#[derive(PartialEq, Eq)]
213struct Checkout {
214 root: PathBuf,
215 git_dir: PathBuf,
216 common: PathBuf,
217}
218
219fn checkout(root: &Path) -> Result<Checkout, MemoryError> {
220 let entry = fs::symlink_metadata(root.join(".git"))?;
221 if entry.file_type().is_symlink() || !(entry.is_file() || entry.is_dir()) {
222 return Err(invalid("unsafe .git entry"));
223 }
224 let top = git_path(root, "--show-toplevel")?;
225 if top != root {
226 return Err(invalid("Git checkout root mismatch"));
227 }
228 let git_dir = git_path(root, "--absolute-git-dir")?;
229 let common = git_path(root, "--git-common-dir")?;
230 if git_dir != common {
231 if git_dir.parent() != Some(common.join("worktrees").as_path()) {
232 return Err(invalid("unregistered Git worktree directory"));
233 }
234 let back = fs::read(git_dir.join("gitdir"))?;
235 let back = back.strip_suffix(b"\n").unwrap_or(&back);
236 let back = path_bytes(back)?;
237 let back = if back.is_absolute() {
238 back
239 } else {
240 git_dir.join(back)
241 };
242 if back.canonicalize()? != root.join(".git").canonicalize()? {
243 return Err(invalid("Git worktree backlink mismatch"));
244 }
245 }
246 Ok(Checkout {
247 root: top,
248 git_dir,
249 common,
250 })
251}
252
253fn registered(current: &Checkout) -> Result<(PathBuf, Vec<PathBuf>), MemoryError> {
257 let bytes = git(¤t.common, &["worktree", "list", "--porcelain", "-z"])?;
258 let mut roots = Vec::new();
259 let mut main = None;
260 for field in bytes.split(|b| *b == 0) {
261 let Some(bytes) = field.strip_prefix(b"worktree ") else {
262 continue;
263 };
264 if roots.len() >= MAX_ROOTS {
265 return Err(invalid("too many registered worktrees"));
266 }
267 let path = path_bytes(bytes)?;
268 let Ok(path) = path.canonicalize() else {
269 continue;
270 };
271 let Ok(candidate) = checkout(&path) else {
272 continue;
273 };
274 if candidate.common != current.common {
275 continue;
276 }
277 if candidate.git_dir == current.common {
278 if main.as_ref().is_some_and(|other| other != &path) {
279 return Err(invalid("ambiguous main checkout"));
280 }
281 main = Some(path.clone());
282 }
283 roots.push(path);
284 }
285 if current.git_dir == current.common {
286 if main.as_ref().is_some_and(|other| other != ¤t.root) {
287 return Err(invalid("main checkout disagrees with registry"));
288 }
289 if main.is_some() {
292 roots.push(current.root.clone());
293 }
294 } else if !roots.contains(¤t.root) {
295 return Err(invalid("current worktree missing from verified registry"));
296 }
297 roots.push(current.root.clone());
298 roots.sort();
299 roots.dedup();
300 Ok((main.unwrap_or_else(|| current.common.clone()), roots))
301}
302
303#[derive(Serialize, Deserialize)]
304#[serde(deny_unknown_fields)]
305struct Marker {
306 version: u32,
307 key: String,
308 initial_root: PathBuf,
309 roots: Vec<PathBuf>,
310}
311
312impl Marker {
313 fn validate(&self) -> Result<(), MemoryError> {
314 ProjectScope::validate_repository_key(&self.key)?;
315 if self.version != 2 || self.roots.len() > MAX_ROOTS || self.roots.is_empty() {
316 return Err(invalid(
317 "unsupported or malformed repository identity marker",
318 ));
319 }
320 for root in std::iter::once(&self.initial_root).chain(&self.roots) {
321 if !root.is_absolute()
322 || root
323 .components()
324 .any(|c| !matches!(c, Component::RootDir | Component::Normal(_)))
325 {
326 return Err(invalid("noncanonical historical repository path"));
327 }
328 ProjectScope::validate_repository_key(
329 ProjectScope::for_canonical_root(root.clone()).key(),
330 )?;
331 }
332 if !self.roots.contains(&self.initial_root) {
333 return Err(invalid("repository marker roots omit its initial root"));
334 }
335 Ok(())
336 }
337}
338
339fn new_repository_key() -> String {
342 loop {
343 let key = format!("p{:016x}", rand::random::<u64>());
344 if ProjectScope::validate_repository_key(&key).is_ok() {
345 return key;
346 }
347 }
348}
349
350#[cfg(not(unix))]
351fn resolve(_: ProjectScope) -> Result<RepositoryIdentity, MemoryError> {
352 Err(invalid(
353 "private repository identity requires Unix filesystem ownership checks",
354 ))
355}
356
357#[cfg(unix)]
358fn acquire_lock(lock: &fs::File) -> Result<(), MemoryError> {
359 let deadline = Instant::now() + WAIT_TIMEOUT;
360 loop {
361 if Instant::now() >= deadline {
362 return Err(invalid("repository marker lock timed out after 5 seconds"));
363 }
364 match fs4::fs_std::FileExt::try_lock_exclusive(lock) {
365 Ok(true) => return Ok(()),
366 Ok(false) => {}
367 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
368 Err(e) => return Err(e.into()),
369 }
370 std::thread::sleep(POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now())));
371 }
372}
373
374#[cfg(unix)]
375fn resolve(legacy: ProjectScope) -> Result<RepositoryIdentity, MemoryError> {
376 let current = checkout(legacy.root())?;
377 let dir = private::Directory::open(¤t.common)?;
378 let lock = dir.open_file(LOCK, libc::O_RDWR | libc::O_CREAT)?;
379 acquire_lock(&lock)?;
380 dir.check_file(LOCK, &lock)?;
381 let (repository_root, roots) = registered(¤t)?;
382 let old = match dir.open_file(MARKER, libc::O_RDONLY) {
383 Ok(file) => Some(file),
384 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
385 Err(e) => return Err(e.into()),
386 };
387 let mut marker = if let Some(file) = &old {
388 use std::io::Read;
389 let mut data = Vec::new();
390 file.take(MAX_BYTES + 1).read_to_end(&mut data)?;
391 if data.len() as u64 > MAX_BYTES {
392 return Err(invalid("repository identity marker too large"));
393 }
394 serde_json::from_slice::<Marker>(&data)?
395 } else {
396 Marker {
397 version: 2,
398 key: new_repository_key(),
399 initial_root: repository_root.clone(),
400 roots: vec![repository_root.clone()],
401 }
402 };
403 marker.validate()?;
404 let before = marker.roots.clone();
405 marker.roots.extend(roots);
406 marker.roots.sort();
407 marker.roots.dedup();
408 marker.validate()?;
409 if checkout(legacy.root())? != current {
412 return Err(invalid("Git layout changed during discovery"));
413 }
414 dir.check_path(¤t.common)?;
415 dir.check_file(LOCK, &lock)?;
416 if let Some(file) = &old {
417 dir.check_file(MARKER, file)?;
418 }
419 if old.is_none() || before != marker.roots {
420 let bytes = serde_json::to_vec(&marker)?;
421 if bytes.len() as u64 > MAX_BYTES {
422 return Err(invalid("repository identity marker too large"));
423 }
424 dir.publish(&bytes, old.is_some())?;
425 }
426 let published = dir.open_file(MARKER, libc::O_RDONLY)?;
427 {
428 use std::io::Read;
429 let mut bytes = Vec::new();
430 (&published).take(MAX_BYTES + 1).read_to_end(&mut bytes)?;
431 let expected = serde_json::to_value(&marker)?;
432 if bytes.len() as u64 > MAX_BYTES
433 || serde_json::from_slice::<serde_json::Value>(&bytes)? != expected
434 {
435 return Err(invalid("repository marker changed during discovery"));
436 }
437 }
438 published.sync_all()?;
440 dir.sync_all()?;
441 dir.check_file(LOCK, &lock)?;
442 dir.check_file(MARKER, &published)?;
443 dir.check_path(¤t.common)?;
444 let scope = ProjectScope::from_key(&repository_root, &marker.key)?;
445 let mut aliases = vec![scope.clone()];
446 for root in marker.roots {
447 let alias = ProjectScope::for_canonical_root(root);
448 if !aliases.iter().any(|s| s.key() == alias.key()) {
449 aliases.push(alias);
450 }
451 }
452 Ok(RepositoryIdentity {
453 scope,
454 legacy_scope: legacy.clone(),
455 aliases,
456 repository_root,
457 worktree_root: legacy.root().to_path_buf(),
458 common_dir: Some(current.common),
459 })
460}
461
462#[cfg(unix)]
463mod private {
464 use super::{LOCK, MARKER};
465 use std::ffi::CString;
466 use std::fs::{File, Metadata};
467 use std::io::{self, Write};
468 use std::os::unix::ffi::OsStrExt;
469 use std::os::unix::fs::MetadataExt;
470 use std::os::unix::io::{AsRawFd, FromRawFd};
471 use std::path::{Component, Path};
472
473 pub(super) struct Directory(File);
474
475 fn denied() -> io::Error {
476 io::Error::new(
477 io::ErrorKind::PermissionDenied,
478 "unsafe or changed repository identity filesystem state",
479 )
480 }
481 fn cstr(bytes: &[u8]) -> io::Result<CString> {
482 CString::new(bytes).map_err(|_| denied())
483 }
484 fn opened(fd: libc::c_int) -> io::Result<File> {
485 if fd < 0 {
486 Err(io::Error::last_os_error())
487 } else {
488 Ok(unsafe { File::from_raw_fd(fd) })
489 }
490 }
491 fn same(a: &Metadata, b: &Metadata) -> bool {
492 a.dev() == b.dev() && a.ino() == b.ino()
493 }
494 fn file_ok(meta: &Metadata) -> io::Result<()> {
495 if !meta.is_file()
496 || meta.uid() != unsafe { libc::geteuid() }
497 || meta.mode() & 0o7777 != 0o600
498 || meta.nlink() != 1
499 {
500 return Err(denied());
501 }
502 Ok(())
503 }
504
505 impl Directory {
506 pub(super) fn open(path: &Path) -> io::Result<Self> {
507 if !path.is_absolute() {
508 return Err(denied());
509 }
510 let mut file = File::open("/")?;
511 let uid = unsafe { libc::geteuid() };
512 for part in path.components() {
513 let Component::Normal(name) = part else {
514 if part == Component::RootDir {
515 continue;
516 }
517 return Err(denied());
518 };
519 let name = cstr(name.as_bytes())?;
520 file = opened(unsafe {
521 libc::openat(
522 file.as_raw_fd(),
523 name.as_ptr(),
524 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
525 )
526 })?;
527 let meta = file.metadata()?;
528 if (meta.uid() != uid && meta.uid() != 0)
533 || (meta.mode() & 0o002 != 0 && !(meta.uid() == 0 && meta.mode() & 0o1000 != 0))
534 {
535 return Err(denied());
536 }
537 }
538 let meta = file.metadata()?;
539 if meta.uid() != uid || meta.mode() & 0o002 != 0 {
540 return Err(denied());
541 }
542 Ok(Self(file))
543 }
544
545 pub(super) fn open_file(&self, name: &str, flags: i32) -> io::Result<File> {
546 let name = cstr(name.as_bytes())?;
547 let file = opened(unsafe {
548 libc::openat(
549 self.0.as_raw_fd(),
550 name.as_ptr(),
551 flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
552 0o600 as libc::c_uint,
555 )
556 })?;
557 file_ok(&file.metadata()?)?;
558 Ok(file)
559 }
560
561 pub(super) fn check_file(&self, name: &str, file: &File) -> io::Result<()> {
562 let meta = file.metadata()?;
563 file_ok(&meta)?;
564 let other = self.open_file(name, libc::O_RDONLY)?;
565 if !same(&meta, &other.metadata()?) {
566 return Err(denied());
567 }
568 Ok(())
569 }
570
571 pub(super) fn check_path(&self, path: &Path) -> io::Result<()> {
572 let other = Self::open(path)?;
573 if !same(&self.0.metadata()?, &other.0.metadata()?) {
574 return Err(denied());
575 }
576 Ok(())
577 }
578
579 pub(super) fn sync_all(&self) -> io::Result<()> {
580 self.0.sync_all()
581 }
582
583 pub(super) fn publish(&self, bytes: &[u8], replace: bool) -> io::Result<()> {
584 let temp = format!("{MARKER}.{}.tmp", uuid::Uuid::new_v4().simple());
585 let mut file = self.open_file(&temp, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL)?;
586 let temp_c = cstr(temp.as_bytes())?;
587 let final_c = cstr(MARKER.as_bytes())?;
588 let fd = self.0.as_raw_fd();
589 let result = (|| {
590 file.write_all(bytes)?;
591 file.sync_all()?;
592 self.check_file(&temp, &file)?;
593 let rc = if replace {
594 self.open_file(MARKER, libc::O_RDONLY)?;
597 unsafe { libc::renameat(fd, temp_c.as_ptr(), fd, final_c.as_ptr()) }
598 } else {
599 unsafe { libc::linkat(fd, temp_c.as_ptr(), fd, final_c.as_ptr(), 0) }
602 };
603 if rc != 0 {
604 return Err(io::Error::last_os_error());
605 }
606 Ok(())
607 })();
608 debug_assert_ne!(temp, LOCK);
610 let unlinked = unsafe { libc::unlinkat(fd, temp_c.as_ptr(), 0) };
611 result?;
612 if unlinked != 0 && !replace {
613 return Err(io::Error::last_os_error());
614 }
615 self.sync_all()
616 }
617 }
618}