Skip to main content

agent_core/memory/
repository.rs

1//! Opt-in, local repository identity; legacy JSONL discovery is unchanged.
2//!
3//! Git's common directory, NOT remotes, defines repository membership. A private
4//! `synaps-memory-identity.json` there retains a random repository key
5//! across moves. Schema (unknown fields rejected): `{"version":2,"key":
6//! "p<16 lowercase hex>","initial_root":"/original/main","roots":["/original/main"]}`.
7//! `key` is `p` plus 16 random lowercase hexadecimal digits, never a path hash.
8//! The reserved all-zero key and unreleased path-key version 1 are refused.
9//! `roots` retains verified canonical historical paths
10//! (including initial_root), bounded to 4096 entries and the marker to 1 MiB.
11//! Readers must verify the current common Git directory and private marker/lock,
12//! historical path hashes are migration candidates only, not canonical identity.
13//! Registered worktrees are verified in both directions before
14//! their path scopes are retained as migration candidates. No records are merged.
15//! An explicit `SYNAPS_PROJECT_ROOT` bypasses Git entirely, just as in legacy
16//! discovery. Without a discoverable main checkout (e.g. a bare/separate gitdir),
17//! the common directory is the reported repository root. Concurrent workers
18//! share the random key durably published under the common-directory lock.
19//!
20//! Persistence requires Unix ownership/mode checks, pinned directory descriptors,
21//! an advisory lock, atomic publication and file/directory fsync. Git processes
22//! and marker lock acquisition each have a five-second timeout. Unsupported
23//! platforms fail closed for Git identity, not for legacy/non-Git scopes. A
24//! same-UID process can deliberately rewrite private state; this is not a sandbox
25//! against the host user. Copying a private marker copies identity; ordinary clones
26//! do not copy Git-private files. Never initialize identity in a live repo in tests.
27// Off Unix `resolve` fails closed before reaching any Git/marker helper, so
28// they are unused there; Unix builds still lint every one of them.
29#![cfg_attr(not(unix), allow(dead_code, unused_imports))]
30
31use super::store::{MemoryError, ProjectScope};
32use serde::{Deserialize, Serialize};
33use std::fs;
34use std::path::{Component, Path, PathBuf};
35use std::process::{Command, Stdio};
36use std::time::{Duration, Instant};
37
38const WAIT_TIMEOUT: Duration = Duration::from_secs(5);
39const POLL_INTERVAL: Duration = Duration::from_millis(10);
40const MARKER: &str = "synaps-memory-identity.json";
41const LOCK: &str = "synaps-memory-identity.lock";
42const MAX_BYTES: u64 = 1024 * 1024;
43const MAX_ROOTS: usize = 4096;
44
45#[cfg(test)]
46thread_local! {
47    /// Git processes spawned by `git()` on this thread (tests only).
48    static GIT_SPAWNS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
49    /// Force the one-option-per-process `rev-parse` path (tests only).
50    static FORCE_SINGLE_REV_PARSE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
51    /// Wait operations performed by `git()` on this thread (tests only).
52    static GIT_WAITS: std::cell::RefCell<Vec<GitWait>> = const { std::cell::RefCell::new(Vec::new()) };
53    /// Reads of Git's stdout to report as WouldBlock before reading (tests only).
54    static FORCE_WOULD_BLOCK: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
55}
56
57/// How `git()` waited (tests only): polling for output, or sleeping before
58/// or after stdout EOF.
59#[cfg(test)]
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum GitWait {
62    PollOutput,
63    Sleep { after_eof: bool, duration: Duration },
64}
65
66#[cfg(test)]
67fn record_wait(wait: GitWait) {
68    GIT_WAITS.with(|w| w.borrow_mut().push(wait));
69}
70
71/// Read Git's nonblocking stdout. Tests can force the next reads to report
72/// WouldBlock, so the not-yet-ready path runs regardless of scheduling.
73#[cfg(unix)]
74fn read_pipe(pipe: &mut impl std::io::Read, buffer: &mut [u8]) -> std::io::Result<usize> {
75    #[cfg(test)]
76    if FORCE_WOULD_BLOCK.with(|n| n.replace(n.get().saturating_sub(1))) > 0 {
77        return Err(std::io::ErrorKind::WouldBlock.into());
78    }
79    pipe.read(buffer)
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct RepositoryIdentity {
84    /// Canonical repository key, with the currently verified repository root.
85    pub scope: ProjectScope,
86    /// Exactly the scope legacy discovery would return for this invocation.
87    pub legacy_scope: ProjectScope,
88    /// Deduplicated migration candidates, including `scope` itself. Historical
89    /// roots were verified when recorded; they need not still exist. This list
90    /// is evidence for an operator, NOT permission to merge or query aliases.
91    pub aliases: Vec<ProjectScope>,
92    /// Current main checkout when verifiable, otherwise the common directory.
93    pub repository_root: PathBuf,
94    /// Current checkout (or explicit override/non-Git legacy root).
95    pub worktree_root: PathBuf,
96    pub common_dir: Option<PathBuf>,
97}
98
99impl ProjectScope {
100    /// Opt-in repository discovery. The explicit `SYNAPS_PROJECT_ROOT` override
101    /// wins and disables Git sharing; malformed Git/private state is an error,
102    /// never a fallback to an unrelated or newly generated scope.
103    pub fn discover_repository(start: &Path) -> Result<RepositoryIdentity, MemoryError> {
104        let root = std::env::var_os("SYNAPS_PROJECT_ROOT").map(PathBuf::from);
105        Self::discover_repository_with_override(start, root.as_deref())
106    }
107
108    /// Environment-free counterpart; `Some(root)` preserves the legacy override
109    /// semantics even if root is a subdirectory/worktree of a Git repository.
110    pub fn discover_repository_with_override(
111        start: &Path,
112        override_root: Option<&Path>,
113    ) -> Result<RepositoryIdentity, MemoryError> {
114        let legacy = Self::discover_with_override(start, override_root)?;
115        Self::validate_repository_key(legacy.key())?;
116        let root = legacy.root().to_path_buf();
117        let has_git = match fs::symlink_metadata(root.join(".git")) {
118            Ok(_) => true,
119            Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
120            Err(e) => return Err(e.into()),
121        };
122        if override_root.is_some() || !has_git {
123            return Ok(RepositoryIdentity {
124                scope: legacy.clone(),
125                legacy_scope: legacy.clone(),
126                aliases: vec![legacy],
127                repository_root: root.clone(),
128                worktree_root: root,
129                common_dir: None,
130            });
131        }
132        resolve(legacy)
133    }
134}
135
136fn invalid(message: impl Into<String>) -> MemoryError {
137    MemoryError::InvalidProjectRoot(message.into())
138}
139
140// Only read-only Git plumbing, no shell/hooks/network. Ignore inherited Git
141// routing/config injection and user/system config. Local repository config is
142// needed for real worktree layouts; Git's own safe.directory check still applies.
143fn git(root: &Path, args: &[&str]) -> Result<Vec<u8>, MemoryError> {
144    git_output(root, args)?.ok_or_else(|| invalid("Git repository verification failed"))
145}
146
147/// `Ok(None)`: Git exited unsuccessfully (the direct child is already reaped).
148#[cfg(not(unix))]
149fn git_output(_: &Path, _: &[&str]) -> Result<Option<Vec<u8>>, MemoryError> {
150    Err(invalid(
151        "bounded Git discovery requires Unix nonblocking pipes",
152    ))
153}
154
155/// No Git at all (macOS: none on `PATH`, no Command Line Tools) says so and
156/// what to do, instead of a bare "No such file or directory".
157#[cfg(unix)]
158fn git_unavailable(e: crate::git_program::GitUnavailable) -> MemoryError {
159    MemoryError::Io(e.to_string())
160}
161
162/// Git before 2.31 has no `rev-parse --path-format`: it echoes the unknown
163/// option as a first output line instead of failing, and prints
164/// `--git-common-dir` relative. Say so instead of "relative path". (The
165/// batched rev-parse needs no check: the extra line sends it to `git_path`.)
166fn require_path_format(bytes: &[u8]) -> Result<&[u8], MemoryError> {
167    if bytes.starts_with(b"--path-format=absolute\n") {
168        return Err(invalid(
169            "Git 2.31 or newer is required (this git has no `rev-parse --path-format`)",
170        ));
171    }
172    Ok(bytes)
173}
174
175/// `Ok(None)`: Git exited unsuccessfully (the direct child is already reaped).
176#[cfg(unix)]
177fn git_output(root: &Path, args: &[&str]) -> Result<Option<Vec<u8>>, MemoryError> {
178    use std::io::ErrorKind;
179    use std::os::fd::AsRawFd;
180    let program = crate::git_program::git_program().map_err(git_unavailable)?;
181    let mut cmd = Command::new(program);
182    for (name, _) in std::env::vars_os() {
183        if name.as_encoded_bytes().starts_with(b"GIT_") {
184            cmd.env_remove(name);
185        }
186    }
187    cmd.env("GIT_CONFIG_NOSYSTEM", "1")
188        .env("GIT_CONFIG_GLOBAL", "/dev/null")
189        .env("GIT_TERMINAL_PROMPT", "0")
190        .arg("--no-optional-locks")
191        .arg("-C")
192        .arg(root)
193        .args(args)
194        .stdin(Stdio::null())
195        .stdout(Stdio::piped())
196        .stderr(Stdio::null());
197    let deadline = Instant::now() + WAIT_TIMEOUT;
198    #[cfg(test)]
199    GIT_SPAWNS.with(|n| n.set(n.get() + 1));
200    let mut child = cmd.spawn()?;
201    let mut stdout = child.stdout.take().unwrap();
202    let timed_out = || invalid("Git discovery timed out after 5 seconds");
203    // A reader thread or blocking read_to_end can outlive the timeout when a
204    // descendant retains stdout. Read the nonblocking pipe, waiting for it with
205    // poll(2) in slices of at most POLL_INTERVAL (no sleep before EOF), and
206    // check the direct child after each slice. A child that fails while a
207    // descendant still holds the pipe is reported at once; one that succeeds
208    // but leaves a descendant holding stdout still waits for EOF until the
209    // deadline and then times out, as before. After EOF the child is reaped
210    // with a bounded sleep backoff (20 us doubling, capped at POLL_INTERVAL).
211    let result = (|| {
212        let fd = stdout.as_raw_fd();
213        let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
214        if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
215            return Err(std::io::Error::last_os_error().into());
216        }
217        let mut bytes = Vec::new();
218        let mut buffer = [0; 8192];
219        loop {
220            if Instant::now() >= deadline {
221                return Err(timed_out());
222            }
223            match read_pipe(&mut stdout, &mut buffer) {
224                Ok(0) => break,
225                Ok(n) => {
226                    if bytes.len() + n > MAX_BYTES as usize {
227                        return Err(invalid("Git discovery output too large"));
228                    }
229                    bytes.extend_from_slice(&buffer[..n]);
230                    continue;
231                }
232                Err(e) if e.kind() == ErrorKind::WouldBlock => {}
233                Err(e) if e.kind() == ErrorKind::Interrupted => continue,
234                Err(e) => return Err(e.into()),
235            }
236            let slice = POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now()));
237            // Round up so a sub-millisecond remainder does not spin.
238            let timeout_ms = slice.as_micros().div_ceil(1000) as libc::c_int;
239            #[cfg(test)]
240            record_wait(GitWait::PollOutput);
241            let mut pfd = libc::pollfd {
242                fd,
243                events: libc::POLLIN,
244                revents: 0,
245            };
246            // POLLIN/POLLHUP/POLLERR all mean the next read makes progress or
247            // reports EOF/error; either way re-check the child and deadline.
248            if unsafe { libc::poll(&mut pfd, 1, timeout_ms) } < 0 {
249                let e = std::io::Error::last_os_error();
250                if e.kind() != ErrorKind::Interrupted {
251                    return Err(e.into());
252                }
253                continue;
254            }
255            if let Some(status) = child.try_wait()? {
256                if !status.success() {
257                    return Ok(None);
258                }
259            }
260        }
261        // EOF: Git normally exits immediately after closing stdout. Reap it
262        // with a short exponential backoff, bounded by the same deadline.
263        let mut backoff = Duration::from_micros(20);
264        loop {
265            if let Some(status) = child.try_wait()? {
266                return Ok(status.success().then_some(bytes));
267            }
268            let remaining = deadline.saturating_duration_since(Instant::now());
269            if remaining.is_zero() {
270                return Err(timed_out());
271            }
272            #[cfg(test)]
273            record_wait(GitWait::Sleep {
274                after_eof: true,
275                duration: backoff.min(remaining),
276            });
277            std::thread::sleep(backoff.min(remaining));
278            backoff = (backoff * 2).min(POLL_INTERVAL);
279        }
280    })();
281    if result.is_err() {
282        // Always terminate/reap the direct child on timeout/read/wait errors.
283        // Never wait for EOF from descendants during cleanup.
284        let _ = child.kill();
285        let _ = child.wait();
286    }
287    result
288}
289
290fn path_bytes(bytes: &[u8]) -> Result<PathBuf, MemoryError> {
291    #[cfg(unix)]
292    {
293        use std::os::unix::ffi::OsStrExt;
294        Ok(PathBuf::from(std::ffi::OsStr::from_bytes(bytes)))
295    }
296    #[cfg(not(unix))]
297    Ok(PathBuf::from(
298        std::str::from_utf8(bytes).map_err(|_| invalid("non-UTF8 Git path"))?,
299    ))
300}
301
302fn absolute_path(bytes: &[u8]) -> Result<PathBuf, MemoryError> {
303    let path = path_bytes(bytes)?;
304    if !path.is_absolute() {
305        return Err(invalid("Git returned a relative path"));
306    }
307    Ok(path.canonicalize()?)
308}
309
310fn git_path(root: &Path, arg: &str) -> Result<PathBuf, MemoryError> {
311    let bytes = git(root, &["rev-parse", "--path-format=absolute", arg])?;
312    let bytes = require_path_format(&bytes)?
313        .strip_suffix(b"\n")
314        .ok_or_else(|| invalid("invalid Git path output"))?;
315    absolute_path(bytes)
316}
317
318const CHECKOUT_PATHS: [&str; 3] = ["--show-toplevel", "--absolute-git-dir", "--git-common-dir"];
319
320/// Exactly three newline-terminated lines, else `None`. A successful
321/// rev-parse prints one line per option, so any other count means a path
322/// contains a newline and the lines cannot be attributed unambiguously.
323fn three_lines(bytes: &[u8]) -> Option<[&[u8]; 3]> {
324    let body = bytes.strip_suffix(b"\n")?;
325    let lines: Vec<&[u8]> = body.split(|b| *b == b'\n').collect();
326    lines.try_into().ok()
327}
328
329/// The `CHECKOUT_PATHS` of `root` in one rev-parse. Unsuccessful Git or an
330/// ambiguous (newline-containing) output repeats the per-option calls, which
331/// keeps their exact results, errors and checking order.
332fn checkout_paths(root: &Path) -> Result<Option<[Vec<u8>; 3]>, MemoryError> {
333    #[cfg(test)]
334    if FORCE_SINGLE_REV_PARSE.with(|f| f.get()) {
335        return Ok(None);
336    }
337    let mut args = vec!["rev-parse", "--path-format=absolute"];
338    args.extend(CHECKOUT_PATHS);
339    let Some(bytes) = git_output(root, &args)? else {
340        return Ok(None);
341    };
342    Ok(three_lines(&bytes).map(|lines| lines.map(<[u8]>::to_vec)))
343}
344
345#[derive(PartialEq, Eq)]
346struct Checkout {
347    root: PathBuf,
348    git_dir: PathBuf,
349    common: PathBuf,
350}
351
352fn checkout(root: &Path) -> Result<Checkout, MemoryError> {
353    let entry = fs::symlink_metadata(root.join(".git"))?;
354    if entry.file_type().is_symlink() || !(entry.is_file() || entry.is_dir()) {
355        return Err(invalid("unsafe .git entry"));
356    }
357    let batched = checkout_paths(root)?;
358    let path = |i: usize| match &batched {
359        Some(lines) => absolute_path(&lines[i]),
360        None => git_path(root, CHECKOUT_PATHS[i]),
361    };
362    let top = path(0)?;
363    if top != root {
364        return Err(invalid("Git checkout root mismatch"));
365    }
366    let git_dir = path(1)?;
367    let common = path(2)?;
368    if git_dir != common {
369        if git_dir.parent() != Some(common.join("worktrees").as_path()) {
370            return Err(invalid("unregistered Git worktree directory"));
371        }
372        let back = fs::read(git_dir.join("gitdir"))?;
373        let back = back.strip_suffix(b"\n").unwrap_or(&back);
374        let back = path_bytes(back)?;
375        let back = if back.is_absolute() {
376            back
377        } else {
378            git_dir.join(back)
379        };
380        if back.canonicalize()? != root.join(".git").canonicalize()? {
381            return Err(invalid("Git worktree backlink mismatch"));
382        }
383    }
384    Ok(Checkout {
385        root: top,
386        git_dir,
387        common,
388    })
389}
390
391// Git enumerates the common-dir worktrees registry (NUL format handles spaces,
392// quotes and newlines). Re-resolving every candidate rejects stale/prunable
393// paths, reused paths and forged one-way links, without touching any records.
394fn registered(current: &Checkout) -> Result<(PathBuf, Vec<PathBuf>), MemoryError> {
395    let bytes = git(&current.common, &["worktree", "list", "--porcelain", "-z"])?;
396    let mut roots = Vec::new();
397    let mut main = None;
398    for field in bytes.split(|b| *b == 0) {
399        let Some(bytes) = field.strip_prefix(b"worktree ") else {
400            continue;
401        };
402        if roots.len() >= MAX_ROOTS {
403            return Err(invalid("too many registered worktrees"));
404        }
405        let path = path_bytes(bytes)?;
406        let Ok(path) = path.canonicalize() else {
407            continue;
408        };
409        let Ok(candidate) = checkout(&path) else {
410            continue;
411        };
412        if candidate.common != current.common {
413            continue;
414        }
415        if candidate.git_dir == current.common {
416            if main.as_ref().is_some_and(|other| other != &path) {
417                return Err(invalid("ambiguous main checkout"));
418            }
419            main = Some(path.clone());
420        }
421        roots.push(path);
422    }
423    if current.git_dir == current.common {
424        if main.as_ref().is_some_and(|other| other != &current.root) {
425            return Err(invalid("main checkout disagrees with registry"));
426        }
427        // Separate-git-dir repositories may not list their main checkout. Do
428        // not report it as the repository root: a worker cannot discover that path.
429        if main.is_some() {
430            roots.push(current.root.clone());
431        }
432    } else if !roots.contains(&current.root) {
433        return Err(invalid("current worktree missing from verified registry"));
434    }
435    roots.push(current.root.clone());
436    roots.sort();
437    roots.dedup();
438    Ok((main.unwrap_or_else(|| current.common.clone()), roots))
439}
440
441#[derive(Serialize, Deserialize)]
442#[serde(deny_unknown_fields)]
443struct Marker {
444    version: u32,
445    key: String,
446    initial_root: PathBuf,
447    roots: Vec<PathBuf>,
448}
449
450impl Marker {
451    fn validate(&self) -> Result<(), MemoryError> {
452        ProjectScope::validate_repository_key(&self.key)?;
453        if self.version != 2 || self.roots.len() > MAX_ROOTS || self.roots.is_empty() {
454            return Err(invalid(
455                "unsupported or malformed repository identity marker",
456            ));
457        }
458        for root in std::iter::once(&self.initial_root).chain(&self.roots) {
459            if !root.is_absolute()
460                || root
461                    .components()
462                    .any(|c| !matches!(c, Component::RootDir | Component::Normal(_)))
463            {
464                return Err(invalid("noncanonical historical repository path"));
465            }
466            ProjectScope::validate_repository_key(
467                ProjectScope::for_canonical_root(root.clone()).key(),
468            )?;
469        }
470        if !self.roots.contains(&self.initial_root) {
471            return Err(invalid("repository marker roots omit its initial root"));
472        }
473        Ok(())
474    }
475}
476
477// Randomness is independent of checkout paths: an unrelated repository may
478// occupy a moved repository's old path without inheriting its canonical DB key.
479fn new_repository_key() -> String {
480    loop {
481        let key = format!("p{:016x}", rand::random::<u64>());
482        if ProjectScope::validate_repository_key(&key).is_ok() {
483            return key;
484        }
485    }
486}
487
488#[cfg(not(unix))]
489fn resolve(_: ProjectScope) -> Result<RepositoryIdentity, MemoryError> {
490    Err(invalid(
491        "private repository identity requires Unix filesystem ownership checks",
492    ))
493}
494
495#[cfg(unix)]
496fn acquire_lock(lock: &fs::File) -> Result<(), MemoryError> {
497    let deadline = Instant::now() + WAIT_TIMEOUT;
498    loop {
499        if Instant::now() >= deadline {
500            return Err(invalid("repository marker lock timed out after 5 seconds"));
501        }
502        match fs4::fs_std::FileExt::try_lock_exclusive(lock) {
503            Ok(true) => return Ok(()),
504            Ok(false) => {}
505            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
506            Err(e) => return Err(e.into()),
507        }
508        std::thread::sleep(POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now())));
509    }
510}
511
512#[cfg(unix)]
513fn resolve(legacy: ProjectScope) -> Result<RepositoryIdentity, MemoryError> {
514    let current = checkout(legacy.root())?;
515    let dir = private::Directory::open(&current.common)?;
516    let lock = dir.open_file(LOCK, libc::O_RDWR | libc::O_CREAT)?;
517    acquire_lock(&lock)?;
518    dir.check_file(LOCK, &lock)?;
519    let (repository_root, roots) = registered(&current)?;
520    let old = match dir.open_file(MARKER, libc::O_RDONLY) {
521        Ok(file) => Some(file),
522        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
523        Err(e) => return Err(e.into()),
524    };
525    let mut marker = if let Some(file) = &old {
526        use std::io::Read;
527        let mut data = Vec::new();
528        file.take(MAX_BYTES + 1).read_to_end(&mut data)?;
529        if data.len() as u64 > MAX_BYTES {
530            return Err(invalid("repository identity marker too large"));
531        }
532        serde_json::from_slice::<Marker>(&data)?
533    } else {
534        Marker {
535            version: 2,
536            key: new_repository_key(),
537            initial_root: repository_root.clone(),
538            roots: vec![repository_root.clone()],
539        }
540    };
541    marker.validate()?;
542    let before = marker.roots.clone();
543    marker.roots.extend(roots);
544    marker.roots.sort();
545    marker.roots.dedup();
546    marker.validate()?;
547    // Revalidate routing and pinned names before publishing/returning. A Git
548    // repair/move racing discovery must be retried, not bind the wrong scope.
549    if checkout(legacy.root())? != current {
550        return Err(invalid("Git layout changed during discovery"));
551    }
552    dir.check_path(&current.common)?;
553    dir.check_file(LOCK, &lock)?;
554    if let Some(file) = &old {
555        dir.check_file(MARKER, file)?;
556    }
557    if old.is_none() || before != marker.roots {
558        let bytes = serde_json::to_vec(&marker)?;
559        if bytes.len() as u64 > MAX_BYTES {
560            return Err(invalid("repository identity marker too large"));
561        }
562        dir.publish(&bytes, old.is_some())?;
563    }
564    let published = dir.open_file(MARKER, libc::O_RDONLY)?;
565    {
566        use std::io::Read;
567        let mut bytes = Vec::new();
568        (&published).take(MAX_BYTES + 1).read_to_end(&mut bytes)?;
569        let expected = serde_json::to_value(&marker)?;
570        if bytes.len() as u64 > MAX_BYTES
571            || serde_json::from_slice::<serde_json::Value>(&bytes)? != expected
572        {
573            return Err(invalid("repository marker changed during discovery"));
574        }
575    }
576    // fsync even on read: a previous writer may have died just after rename.
577    published.sync_all()?;
578    dir.sync_all()?;
579    dir.check_file(LOCK, &lock)?;
580    dir.check_file(MARKER, &published)?;
581    dir.check_path(&current.common)?;
582    let scope = ProjectScope::from_key(&repository_root, &marker.key)?;
583    let mut aliases = vec![scope.clone()];
584    for root in marker.roots {
585        let alias = ProjectScope::for_canonical_root(root);
586        if !aliases.iter().any(|s| s.key() == alias.key()) {
587            aliases.push(alias);
588        }
589    }
590    Ok(RepositoryIdentity {
591        scope,
592        legacy_scope: legacy.clone(),
593        aliases,
594        repository_root,
595        worktree_root: legacy.root().to_path_buf(),
596        common_dir: Some(current.common),
597    })
598}
599
600#[cfg(unix)]
601mod private {
602    use super::{LOCK, MARKER};
603    use std::ffi::CString;
604    use std::fs::{File, Metadata};
605    use std::io::{self, Write};
606    use std::os::unix::ffi::OsStrExt;
607    use std::os::unix::fs::MetadataExt;
608    use std::os::unix::io::{AsRawFd, FromRawFd};
609    use std::path::{Component, Path};
610
611    pub(super) struct Directory(File);
612
613    fn denied() -> io::Error {
614        io::Error::new(
615            io::ErrorKind::PermissionDenied,
616            "unsafe or changed repository identity filesystem state",
617        )
618    }
619    fn cstr(bytes: &[u8]) -> io::Result<CString> {
620        CString::new(bytes).map_err(|_| denied())
621    }
622    fn opened(fd: libc::c_int) -> io::Result<File> {
623        if fd < 0 {
624            Err(io::Error::last_os_error())
625        } else {
626            Ok(unsafe { File::from_raw_fd(fd) })
627        }
628    }
629    fn same(a: &Metadata, b: &Metadata) -> bool {
630        a.dev() == b.dev() && a.ino() == b.ino()
631    }
632    fn file_ok(meta: &Metadata) -> io::Result<()> {
633        if !meta.is_file()
634            || meta.uid() != unsafe { libc::geteuid() }
635            || meta.mode() & 0o7777 != 0o600
636            || meta.nlink() != 1
637        {
638            return Err(denied());
639        }
640        Ok(())
641    }
642
643    impl Directory {
644        pub(super) fn open(path: &Path) -> io::Result<Self> {
645            if !path.is_absolute() {
646                return Err(denied());
647            }
648            let mut file = File::open("/")?;
649            let uid = unsafe { libc::geteuid() };
650            for part in path.components() {
651                let Component::Normal(name) = part else {
652                    if part == Component::RootDir {
653                        continue;
654                    }
655                    return Err(denied());
656                };
657                let name = cstr(name.as_bytes())?;
658                file = opened(unsafe {
659                    libc::openat(
660                        file.as_raw_fd(),
661                        name.as_ptr(),
662                        libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
663                    )
664                })?;
665                let meta = file.metadata()?;
666                // Root-owned sticky /tmp is safe. Owner-owned group-writable
667                // project layouts are supported; the private marker/lock remain
668                // 0600. This is not a sandbox against the host user's group.
669                // Never chmod or take ownership of Git directories.
670                if (meta.uid() != uid && meta.uid() != 0)
671                    || (meta.mode() & 0o002 != 0 && !(meta.uid() == 0 && meta.mode() & 0o1000 != 0))
672                {
673                    return Err(denied());
674                }
675            }
676            let meta = file.metadata()?;
677            if meta.uid() != uid || meta.mode() & 0o002 != 0 {
678                return Err(denied());
679            }
680            Ok(Self(file))
681        }
682
683        pub(super) fn open_file(&self, name: &str, flags: i32) -> io::Result<File> {
684            let name = cstr(name.as_bytes())?;
685            let file = opened(unsafe {
686                libc::openat(
687                    self.0.as_raw_fd(),
688                    name.as_ptr(),
689                    flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
690                    // Variadic mode: C promotes it to unsigned int, and Rust
691                    // refuses a u16 (`mode_t` on macOS) in a variadic call.
692                    0o600 as libc::c_uint,
693                )
694            })?;
695            file_ok(&file.metadata()?)?;
696            Ok(file)
697        }
698
699        pub(super) fn check_file(&self, name: &str, file: &File) -> io::Result<()> {
700            let meta = file.metadata()?;
701            file_ok(&meta)?;
702            let other = self.open_file(name, libc::O_RDONLY)?;
703            if !same(&meta, &other.metadata()?) {
704                return Err(denied());
705            }
706            Ok(())
707        }
708
709        pub(super) fn check_path(&self, path: &Path) -> io::Result<()> {
710            let other = Self::open(path)?;
711            if !same(&self.0.metadata()?, &other.0.metadata()?) {
712                return Err(denied());
713            }
714            Ok(())
715        }
716
717        pub(super) fn sync_all(&self) -> io::Result<()> {
718            self.0.sync_all()
719        }
720
721        pub(super) fn publish(&self, bytes: &[u8], replace: bool) -> io::Result<()> {
722            let temp = format!("{MARKER}.{}.tmp", uuid::Uuid::new_v4().simple());
723            let mut file = self.open_file(&temp, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL)?;
724            let temp_c = cstr(temp.as_bytes())?;
725            let final_c = cstr(MARKER.as_bytes())?;
726            let fd = self.0.as_raw_fd();
727            let result = (|| {
728                file.write_all(bytes)?;
729                file.sync_all()?;
730                self.check_file(&temp, &file)?;
731                let rc = if replace {
732                    // Under the persistent advisory lock. Refuse unsafe targets
733                    // rather than repairing/replacing a planted link or mode.
734                    self.open_file(MARKER, libc::O_RDONLY)?;
735                    unsafe { libc::renameat(fd, temp_c.as_ptr(), fd, final_c.as_ptr()) }
736                } else {
737                    // No-clobber atomic first publication: even a noncooperating
738                    // creator cannot have its marker silently overwritten.
739                    unsafe { libc::linkat(fd, temp_c.as_ptr(), fd, final_c.as_ptr(), 0) }
740                };
741                if rc != 0 {
742                    return Err(io::Error::last_os_error());
743                }
744                Ok(())
745            })();
746            // Never unlink the lock. Unlink only our uniquely named temp entry.
747            debug_assert_ne!(temp, LOCK);
748            let unlinked = unsafe { libc::unlinkat(fd, temp_c.as_ptr(), 0) };
749            result?;
750            if unlinked != 0 && !replace {
751                return Err(io::Error::last_os_error());
752            }
753            self.sync_all()
754        }
755    }
756}
757
758#[cfg(all(test, unix))]
759mod tests {
760    use super::*;
761    // Fixture tests create directories (racing the crate's umask-mutating
762    // tests) and spawn Git. A forked child briefly inherits other threads'
763    // descriptors, including held advisory locks, until it executes Git; tests
764    // that require uncontended try-locks join `process_spawn` as `parallel`.
765    use serial_test::serial;
766    use tempfile::TempDir;
767
768    fn fixture_git(root: &Path, args: &[&str]) {
769        let mut command = Command::new("git");
770        for (name, _) in std::env::vars_os() {
771            if name.as_encoded_bytes().starts_with(b"GIT_") {
772                command.env_remove(name);
773            }
774        }
775        let output = command
776            .env("GIT_CONFIG_NOSYSTEM", "1")
777            .env("GIT_CONFIG_GLOBAL", "/dev/null")
778            .env("HOME", root)
779            .args([
780                "-c",
781                "init.defaultBranch=main",
782                "-c",
783                "commit.gpgsign=false",
784            ])
785            .args([
786                "-c",
787                "user.name=Synthetic",
788                "-c",
789                "user.email=test@example.invalid",
790            ])
791            .arg("-C")
792            .arg(root)
793            .args(args)
794            .output()
795            .unwrap();
796        assert!(
797            output.status.success(),
798            "{args:?}: {}",
799            String::from_utf8_lossy(&output.stderr)
800        );
801    }
802
803    /// A synthetic repository with `extra` plain worktrees plus the given
804    /// specially named ones. Returns (main, all worktree roots).
805    fn fixture(tmp: &TempDir, extra: usize, named: &[&str]) -> (PathBuf, Vec<PathBuf>) {
806        let main = tmp.path().join("main");
807        fs::create_dir(&main).unwrap();
808        fixture_git(&main, &["init", "-q"]);
809        fixture_git(&main, &["commit", "-q", "--allow-empty", "-m", "synthetic"]);
810        let main = main.canonicalize().unwrap();
811        let names = (0..extra)
812            .map(|i| format!("wt{i}"))
813            .chain(named.iter().map(|s| s.to_string()));
814        let mut roots = Vec::new();
815        for (i, name) in names.enumerate() {
816            let path = tmp.path().join(name);
817            let branch = format!("b{i}");
818            fixture_git(
819                &main,
820                &[
821                    "worktree",
822                    "add",
823                    "-q",
824                    "-b",
825                    &branch,
826                    path.to_str().unwrap(),
827                ],
828            );
829            roots.push(path.canonicalize().unwrap());
830        }
831        (main, roots)
832    }
833
834    fn spawns<T>(f: impl FnOnce() -> T) -> (T, usize) {
835        let before = GIT_SPAWNS.with(|n| n.get());
836        let value = f();
837        (value, GIT_SPAWNS.with(|n| n.get()) - before)
838    }
839
840    fn single<T>(f: impl FnOnce() -> T) -> T {
841        FORCE_SINGLE_REV_PARSE.with(|f| f.set(true));
842        let value = f();
843        FORCE_SINGLE_REV_PARSE.with(|f| f.set(false));
844        value
845    }
846
847    fn discover(root: &Path) -> RepositoryIdentity {
848        ProjectScope::discover_repository_with_override(root, None).unwrap()
849    }
850
851    #[test]
852    fn unavailable_git_names_the_fix() {
853        let message = git_unavailable(crate::git_program::GitUnavailable).to_string();
854        assert!(message.contains("git unavailable"), "{message}");
855        assert!(message.contains("xcode-select --install"), "{message}");
856    }
857
858    #[test]
859    fn git_without_path_format_is_named() {
860        // What Git 2.30 prints for `rev-parse --path-format=absolute
861        // --show-toplevel --absolute-git-dir --git-common-dir`.
862        let old = b"--path-format=absolute\n/r\n/r/.git\n.git\n";
863        let message = require_path_format(old).unwrap_err().to_string();
864        assert!(
865            message.contains("Git 2.31 or newer is required"),
866            "{message}"
867        );
868        let new = b"/r\n/r/.git\n/r/.git\n";
869        assert_eq!(require_path_format(new).unwrap(), new);
870    }
871
872    #[test]
873    #[serial(umask, process_spawn)]
874    fn discovery_spawns_one_git_per_registered_worktree() {
875        let tmp = TempDir::new().unwrap();
876        let (main, roots) = fixture(&tmp, 40, &[]);
877        // N+1 checkouts, one worktree listing, one current-checkout re-check.
878        let budget = roots.len() + 1 + 4;
879        for start in [&main, &roots[7]] {
880            let (identity, count) = spawns(|| discover(start));
881            assert_eq!(identity.aliases.len(), roots.len() + 2);
882            assert!(count <= budget, "{count} Git spawns > {budget}");
883        }
884    }
885
886    #[test]
887    #[serial(umask, process_spawn)]
888    fn checkout_batches_rev_parse_and_newline_paths_fall_back() {
889        let tmp = TempDir::new().unwrap();
890        let (main, roots) = fixture(&tmp, 1, &["worker a", "worker\n\"b"]);
891        for root in [&main, &roots[0], &roots[1]] {
892            let (batched, count) = spawns(|| checkout(root).unwrap());
893            assert_eq!(count, 1, "{root:?}");
894            assert!(batched == single(|| checkout(root).unwrap()));
895        }
896        // Four output lines: identical result via the per-option fallback.
897        let newline = &roots[2];
898        let (batched, count) = spawns(|| checkout(newline).unwrap());
899        assert_eq!(count, 4);
900        assert!(batched == single(|| checkout(newline).unwrap()));
901        assert_eq!(&batched.root, newline);
902    }
903
904    #[test]
905    #[serial(umask, process_spawn)]
906    fn batched_and_single_rev_parse_resolve_identical_identity() {
907        let tmp = TempDir::new().unwrap();
908        let (main, roots) = fixture(&tmp, 3, &["worker a", "worker\n\"b"]);
909        for start in std::iter::once(&main).chain(&roots) {
910            let slow = single(|| discover(start));
911            let fast = discover(start);
912            assert_eq!(fast, slow);
913            assert_eq!(fast.aliases.len(), roots.len() + 2);
914        }
915        assert!(single(|| checkout(&main)).unwrap() == checkout(&main).unwrap());
916    }
917
918    #[test]
919    fn rev_parse_output_needs_exactly_three_terminated_lines() {
920        let ok: [&[u8]; 3] = [b"/a", b"/a/.git", b"/a/.git"];
921        assert_eq!(three_lines(b"/a\n/a/.git\n/a/.git\n"), Some(ok));
922        for bad in [
923            &b""[..],
924            b"\n",
925            b"/a\n/a/.git\n/a/.git",
926            b"/a\n/a/.git\n",
927            b"/x\ny\n/x\ny/.git\n/x\ny/.git\n",
928            b"/a\n/a/.git\n/a/.git\n\n",
929        ] {
930            assert_eq!(three_lines(bad), None, "{bad:?}");
931        }
932    }
933
934    // Checks the kind and order of waits, not elapsed time: before EOF git()
935    // must wait for its output with poll(2), never with a fixed sleep (the
936    // old loop slept 10 ms). Only the bounded post-EOF reaping backoff may
937    // sleep, starting far below that. The first read of every call is forced
938    // to report WouldBlock, so the not-yet-ready path runs even when Git's
939    // output is already available. Elapsed time is measured only by the
940    // ignored benchmark.
941    #[test]
942    #[serial(umask, process_spawn)]
943    fn git_waits_for_output_by_poll_not_fixed_sleep() {
944        let tmp = TempDir::new().unwrap();
945        let (main, _) = fixture(&tmp, 0, &[]);
946        for _ in 0..10 {
947            GIT_WAITS.with(|w| w.borrow_mut().clear());
948            FORCE_WOULD_BLOCK.with(|n| n.set(1));
949            let output = git(&main, &["rev-parse", "--git-common-dir"]).unwrap();
950            assert_eq!(output, b".git\n");
951            assert_eq!(FORCE_WOULD_BLOCK.with(|n| n.get()), 0);
952            let waits = GIT_WAITS.with(|w| w.take());
953            // At least one pre-EOF wait, all polls; then only reaping sleeps.
954            assert_eq!(waits.first(), Some(&GitWait::PollOutput), "{waits:?}");
955            let reaping = waits
956                .iter()
957                .position(|w| matches!(w, GitWait::Sleep { .. }))
958                .unwrap_or(waits.len());
959            for (i, wait) in waits.iter().enumerate() {
960                match *wait {
961                    GitWait::PollOutput => assert!(i < reaping, "{waits:?}"),
962                    GitWait::Sleep {
963                        after_eof,
964                        duration,
965                    } => {
966                        assert!(after_eof, "fixed sleep before EOF: {waits:?}");
967                        if i == reaping {
968                            assert!(duration < Duration::from_millis(1), "{waits:?}");
969                        }
970                    }
971                }
972            }
973        }
974    }
975
976    /// `cargo test -p synaps-core --lib -- --ignored --nocapture discovery_bench`
977    #[test]
978    #[serial(umask, process_spawn)]
979    #[ignore]
980    fn discovery_bench_132_worktrees() {
981        let tmp = TempDir::new().unwrap();
982        let (main, roots) = fixture(&tmp, 132, &[]);
983        discover(&main);
984        let mut times = Vec::new();
985        let mut count = 0;
986        for _ in 0..5 {
987            let started = Instant::now();
988            count = spawns(|| discover(&roots[50])).1;
989            times.push(started.elapsed());
990        }
991        times.sort();
992        println!(
993            "discovery 132 worktrees: median {:?} min {:?} max {:?}, {count} Git spawns",
994            times[2], times[0], times[4]
995        );
996    }
997}