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