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#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct RepositoryIdentity {
44    /// Canonical repository key, with the currently verified repository root.
45    pub scope: ProjectScope,
46    /// Exactly the scope legacy discovery would return for this invocation.
47    pub legacy_scope: ProjectScope,
48    /// Deduplicated migration candidates, including `scope` itself. Historical
49    /// roots were verified when recorded; they need not still exist. This list
50    /// is evidence for an operator, NOT permission to merge or query aliases.
51    pub aliases: Vec<ProjectScope>,
52    /// Current main checkout when verifiable, otherwise the common directory.
53    pub repository_root: PathBuf,
54    /// Current checkout (or explicit override/non-Git legacy root).
55    pub worktree_root: PathBuf,
56    pub common_dir: Option<PathBuf>,
57}
58
59impl ProjectScope {
60    /// Opt-in repository discovery. The explicit `SYNAPS_PROJECT_ROOT` override
61    /// wins and disables Git sharing; malformed Git/private state is an error,
62    /// never a fallback to an unrelated or newly generated scope.
63    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    /// Environment-free counterpart; `Some(root)` preserves the legacy override
69    /// semantics even if root is a subdirectory/worktree of a Git repository.
70    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// Only read-only Git plumbing, no shell/hooks/network. Ignore inherited Git
101// routing/config injection and user/system config. Local repository config is
102// needed for real worktree layouts; Git's own safe.directory check still applies.
103#[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    // A reader thread or blocking read_to_end can outlive the timeout when a
134    // descendant retains stdout. Poll the nonblocking pipe and child together.
135    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        // Always terminate/reap the direct child on timeout/read/wait errors.
181        // Never wait for EOF from descendants during cleanup.
182        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
253// Git enumerates the common-dir worktrees registry (NUL format handles spaces,
254// quotes and newlines). Re-resolving every candidate rejects stale/prunable
255// paths, reused paths and forged one-way links, without touching any records.
256fn registered(current: &Checkout) -> Result<(PathBuf, Vec<PathBuf>), MemoryError> {
257    let bytes = git(&current.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 != &current.root) {
287            return Err(invalid("main checkout disagrees with registry"));
288        }
289        // Separate-git-dir repositories may not list their main checkout. Do
290        // not report it as the repository root: a worker cannot discover that path.
291        if main.is_some() {
292            roots.push(current.root.clone());
293        }
294    } else if !roots.contains(&current.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
339// Randomness is independent of checkout paths: an unrelated repository may
340// occupy a moved repository's old path without inheriting its canonical DB key.
341fn 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(&current.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(&current)?;
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    // Revalidate routing and pinned names before publishing/returning. A Git
410    // repair/move racing discovery must be retried, not bind the wrong scope.
411    if checkout(legacy.root())? != current {
412        return Err(invalid("Git layout changed during discovery"));
413    }
414    dir.check_path(&current.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    // fsync even on read: a previous writer may have died just after rename.
439    published.sync_all()?;
440    dir.sync_all()?;
441    dir.check_file(LOCK, &lock)?;
442    dir.check_file(MARKER, &published)?;
443    dir.check_path(&current.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                // Root-owned sticky /tmp is safe. Owner-owned group-writable
529                // project layouts are supported; the private marker/lock remain
530                // 0600. This is not a sandbox against the host user's group.
531                // Never chmod or take ownership of Git directories.
532                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                    // Variadic mode: C promotes it to unsigned int, and Rust
553                    // refuses a u16 (`mode_t` on macOS) in a variadic call.
554                    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                    // Under the persistent advisory lock. Refuse unsafe targets
595                    // rather than repairing/replacing a planted link or mode.
596                    self.open_file(MARKER, libc::O_RDONLY)?;
597                    unsafe { libc::renameat(fd, temp_c.as_ptr(), fd, final_c.as_ptr()) }
598                } else {
599                    // No-clobber atomic first publication: even a noncooperating
600                    // creator cannot have its marker silently overwritten.
601                    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            // Never unlink the lock. Unlink only our uniquely named temp entry.
609            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}