Skip to main content

code_system_graph_core/
capability_dir.rs

1//! Capability-scoped directory access that never follows symbolic links.
2
3use std::ffi::OsStr;
4use std::fs::{self, File};
5use std::io::{Read, Write};
6#[cfg(unix)]
7use std::os::unix::fs::OpenOptionsExt;
8use std::path::{Component, Path, PathBuf};
9
10use thiserror::Error;
11
12/// Maximum repository-local configuration size accepted from analyzed checkouts.
13pub const MAX_REPOSITORY_CONFIG_BYTES: usize = 1024 * 1024;
14
15/// Error returned while performing capability-scoped filesystem access.
16#[derive(Debug, Error)]
17pub enum CapabilityError {
18    /// A filesystem operation failed.
19    #[error("failed to access `{path}`: {source}")]
20    Io {
21        /// Affected path.
22        path: PathBuf,
23        /// Underlying operating-system error.
24        #[source]
25        source: std::io::Error,
26    },
27    /// A symbolic link or reparse point was encountered.
28    #[error("`{path}` is a symbolic link or reparse point")]
29    Symlink {
30        /// Rejected path.
31        path: PathBuf,
32    },
33    /// The target is not a regular file.
34    #[error("`{path}` is not a regular file")]
35    NotRegularFile {
36        /// Rejected path.
37        path: PathBuf,
38    },
39    /// The target is not a directory.
40    #[error("`{path}` is not a directory")]
41    NotDirectory {
42        /// Rejected path.
43        path: PathBuf,
44    },
45    /// A relative path escapes the authorized root.
46    #[error("`{path}` escapes the authorized root `{root}`")]
47    OutsideRoot {
48        /// Requested relative path.
49        path: PathBuf,
50        /// Canonical authorized root.
51        root: PathBuf,
52    },
53    /// A relative path is malformed.
54    #[error("relative path `{path}` is invalid")]
55    InvalidRelativePath {
56        /// Rejected relative path.
57        path: PathBuf,
58    },
59    /// A file exceeds the configured byte limit.
60    #[error("`{path}` exceeds {limit} bytes")]
61    TooLarge {
62        /// Rejected path.
63        path: PathBuf,
64        /// Maximum accepted size in bytes.
65        limit: usize,
66    },
67    /// File contents are not valid UTF-8.
68    #[error("`{path}` is not valid UTF-8")]
69    InvalidUtf8 {
70        /// Rejected path.
71        path: PathBuf,
72    },
73}
74
75/// Handles scoped directory operations relative to a canonical checkout root.
76pub struct CapabilityDir {
77    root: PathBuf,
78    #[cfg(unix)]
79    directory: File,
80}
81
82/// Classification of a repository-relative path for regular-file reads.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum RegularFileEntry {
85    /// No entry exists at the relative path.
86    Absent,
87    /// A regular file exists at the relative path.
88    Regular,
89}
90
91impl CapabilityDir {
92    /// Opens one canonical directory without following a symlink root.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`CapabilityError`] when the root cannot be canonicalized, is not a directory, or is
97    /// a symbolic link.
98    pub fn open(root: &Path) -> Result<Self, CapabilityError> {
99        let canonical = fs::canonicalize(root).map_err(|source| CapabilityError::Io {
100            path: root.to_path_buf(),
101            source,
102        })?;
103        let metadata = fs::symlink_metadata(&canonical).map_err(|source| CapabilityError::Io {
104            path: canonical.clone(),
105            source,
106        })?;
107        if is_symlink_or_reparse_point(&metadata) {
108            return Err(CapabilityError::Symlink { path: canonical });
109        }
110        if !metadata.is_dir() {
111            return Err(CapabilityError::NotDirectory { path: canonical });
112        }
113        #[cfg(unix)]
114        let directory = open_directory_nofollow(&canonical)?;
115        Ok(Self {
116            root: canonical,
117            #[cfg(unix)]
118            directory,
119        })
120    }
121
122    /// Returns the canonical authorized root path.
123    #[must_use]
124    pub fn root(&self) -> &Path {
125        &self.root
126    }
127
128    /// Reads a bounded UTF-8 file relative to the authorized root.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`CapabilityError`] when the relative path is invalid, the file is unsafe, or the
133    /// read exceeds `max_bytes`.
134    pub fn read_utf8_file_bounded(
135        &self,
136        relative: &Path,
137        max_bytes: usize,
138    ) -> Result<String, CapabilityError> {
139        let bytes = self.read_file_bounded(relative, max_bytes)?;
140        String::from_utf8(bytes).map_err(|_| CapabilityError::InvalidUtf8 {
141            path: self.root.join(relative),
142        })
143    }
144
145    /// Reads a bounded file relative to the authorized root.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`CapabilityError`] when the relative path is invalid, the file is unsafe, or the
150    /// read exceeds `max_bytes`.
151    pub fn read_file_bounded(
152        &self,
153        relative: &Path,
154        max_bytes: usize,
155    ) -> Result<Vec<u8>, CapabilityError> {
156        validate_relative_path(relative, &self.root)?;
157        let joined = self.root.join(relative);
158        let metadata = fs::symlink_metadata(&joined).map_err(|source| CapabilityError::Io {
159            path: joined.clone(),
160            source,
161        })?;
162        if is_symlink_or_reparse_point(&metadata) {
163            return Err(CapabilityError::Symlink { path: joined });
164        }
165        if !metadata.is_file() {
166            return Err(CapabilityError::NotRegularFile { path: joined });
167        }
168        #[cfg(unix)]
169        {
170            read_file_bounded_unix(&self.directory, &self.root, relative, max_bytes)
171        }
172        #[cfg(not(unix))]
173        {
174            read_file_bounded_portable(&self.root, relative, max_bytes)
175        }
176    }
177
178    /// Classifies whether a relative path is absent, a regular file, or an unsafe non-regular entry.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`CapabilityError`] when the relative path is invalid, a symlink, or unreadable.
183    pub fn classify_regular_file_entry(
184        &self,
185        relative: &Path,
186    ) -> Result<RegularFileEntry, CapabilityError> {
187        validate_relative_path(relative, &self.root)?;
188        let path = self.root.join(relative);
189        match fs::symlink_metadata(&path) {
190            Ok(metadata) => {
191                if is_symlink_or_reparse_point(&metadata) {
192                    return Err(CapabilityError::Symlink { path });
193                }
194                if metadata.is_file() {
195                    Ok(RegularFileEntry::Regular)
196                } else {
197                    Err(CapabilityError::NotRegularFile { path })
198                }
199            }
200            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
201                Ok(RegularFileEntry::Absent)
202            }
203            Err(source) => Err(CapabilityError::Io { path, source }),
204        }
205    }
206
207    /// Returns whether a regular file exists at a relative path.
208    ///
209    /// # Errors
210    ///
211    /// Returns [`CapabilityError`] when the relative path is invalid or resolves to an unsafe
212    /// entry.
213    pub fn regular_file_exists(&self, relative: &Path) -> Result<bool, CapabilityError> {
214        validate_relative_path(relative, &self.root)?;
215        let path = self.root.join(relative);
216        match fs::symlink_metadata(&path) {
217            Ok(metadata) => {
218                if is_symlink_or_reparse_point(&metadata) {
219                    return Err(CapabilityError::Symlink { path });
220                }
221                Ok(metadata.is_file())
222            }
223            Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
224            Err(source) => Err(CapabilityError::Io { path, source }),
225        }
226    }
227
228    /// Atomically writes bytes to a relative file, creating parent directories as needed.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`CapabilityError`] when the relative path is invalid, a parent is unsafe, or the
233    /// write fails.
234    pub fn atomic_write(&self, relative: &Path, bytes: &[u8]) -> Result<(), CapabilityError> {
235        validate_relative_path(relative, &self.root)?;
236        #[cfg(unix)]
237        {
238            let (parent, file_name) = split_relative(relative)?;
239            let parent_dir = descend_unix(&self.directory, &self.root, parent, true)?;
240            atomic_write_unix(&parent_dir, &self.root.join(parent), file_name, bytes)
241        }
242        #[cfg(not(unix))]
243        {
244            atomic_write_portable(&self.root, relative, bytes)
245        }
246    }
247
248    /// Removes a regular file relative to the authorized root when it exists.
249    ///
250    /// # Errors
251    ///
252    /// Returns [`CapabilityError`] when the relative path is invalid or the target is unsafe.
253    pub fn remove_file_if_exists(&self, relative: &Path) -> Result<bool, CapabilityError> {
254        validate_relative_path(relative, &self.root)?;
255        #[cfg(unix)]
256        {
257            let (parent, file_name) = split_relative(relative)?;
258            let parent_dir = match descend_unix(&self.directory, &self.root, parent, false) {
259                Ok(directory) => directory,
260                Err(CapabilityError::Io { source, .. })
261                    if source.kind() == std::io::ErrorKind::NotFound =>
262                {
263                    return Ok(false);
264                }
265                Err(error) => return Err(error),
266            };
267            remove_file_unix(&parent_dir, &self.root.join(parent), file_name)
268        }
269        #[cfg(not(unix))]
270        {
271            remove_file_portable(&self.root, relative)
272        }
273    }
274}
275
276fn validate_relative_path(relative: &Path, root: &Path) -> Result<(), CapabilityError> {
277    if relative.is_absolute() {
278        return Err(CapabilityError::InvalidRelativePath {
279            path: relative.to_path_buf(),
280        });
281    }
282    for component in relative.components() {
283        match component {
284            Component::Normal(_) | Component::CurDir => {}
285            Component::ParentDir | Component::Prefix(_) | Component::RootDir => {
286                return Err(CapabilityError::InvalidRelativePath {
287                    path: relative.to_path_buf(),
288                });
289            }
290        }
291    }
292    let joined = root.join(relative);
293    if let Ok(canonical) = fs::canonicalize(&joined)
294        && !canonical.starts_with(root)
295    {
296        return Err(CapabilityError::OutsideRoot {
297            path: relative.to_path_buf(),
298            root: root.to_path_buf(),
299        });
300    }
301    Ok(())
302}
303
304fn split_relative(relative: &Path) -> Result<(&Path, &OsStr), CapabilityError> {
305    let file_name = relative
306        .file_name()
307        .ok_or_else(|| CapabilityError::InvalidRelativePath {
308            path: relative.to_path_buf(),
309        })?;
310    let parent = relative.parent().unwrap_or_else(|| Path::new(""));
311    Ok((parent, file_name))
312}
313
314fn is_symlink_or_reparse_point(metadata: &fs::Metadata) -> bool {
315    metadata.file_type().is_symlink() || {
316        #[cfg(windows)]
317        {
318            use std::os::windows::fs::MetadataExt;
319            metadata.file_attributes() & 0x400 != 0
320        }
321        #[cfg(not(windows))]
322        {
323            false
324        }
325    }
326}
327
328/// Walks one relative directory chain without following symbolic links or reparse points.
329#[cfg_attr(unix, allow(dead_code))]
330fn walk_directory_chain(
331    root: &Path,
332    relative: &Path,
333    create: bool,
334) -> Result<PathBuf, CapabilityError> {
335    if relative.as_os_str().is_empty() {
336        return Ok(root.to_path_buf());
337    }
338
339    let mut current = root.to_path_buf();
340    for component in relative.components() {
341        let Component::Normal(name) = component else {
342            continue;
343        };
344        current.push(name);
345        match fs::symlink_metadata(&current) {
346            Ok(metadata) => {
347                if is_symlink_or_reparse_point(&metadata) {
348                    return Err(CapabilityError::Symlink {
349                        path: current.clone(),
350                    });
351                }
352                if !metadata.is_dir() {
353                    return Err(CapabilityError::NotDirectory { path: current });
354                }
355            }
356            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
357                if !create {
358                    return Err(CapabilityError::Io {
359                        path: current.clone(),
360                        source,
361                    });
362                }
363                fs::create_dir(&current).map_err(|source| CapabilityError::Io {
364                    path: current.clone(),
365                    source,
366                })?;
367                let metadata =
368                    fs::symlink_metadata(&current).map_err(|source| CapabilityError::Io {
369                        path: current.clone(),
370                        source,
371                    })?;
372                if is_symlink_or_reparse_point(&metadata) {
373                    return Err(CapabilityError::Symlink { path: current });
374                }
375                if !metadata.is_dir() {
376                    return Err(CapabilityError::NotDirectory { path: current });
377                }
378            }
379            Err(source) => {
380                return Err(CapabilityError::Io {
381                    path: current,
382                    source,
383                });
384            }
385        }
386    }
387    Ok(current)
388}
389
390#[cfg(unix)]
391fn open_directory_nofollow(path: &Path) -> Result<File, CapabilityError> {
392    use std::fs::OpenOptions;
393
394    OpenOptions::new()
395        .read(true)
396        .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
397        .open(path)
398        .map_err(|source| CapabilityError::Io {
399            path: path.to_path_buf(),
400            source,
401        })
402}
403
404#[cfg(unix)]
405fn descend_unix(
406    directory: &File,
407    root: &Path,
408    relative: &Path,
409    create: bool,
410) -> Result<File, CapabilityError> {
411    use nix::fcntl::{OFlag, openat};
412    use nix::sys::stat::{Mode, mkdirat};
413
414    if relative.as_os_str().is_empty() {
415        return open_directory_nofollow(root);
416    }
417
418    let mut current_path = root.to_path_buf();
419    let mut handle = None::<File>;
420
421    for component in relative.components() {
422        let Component::Normal(name) = component else {
423            continue;
424        };
425        let parent = handle.as_ref().unwrap_or(directory);
426        let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
427        let opened = match openat(parent, name, flags, Mode::empty()) {
428            Ok(fd) => File::from(fd),
429            Err(nix::errno::Errno::ENOENT) if create => {
430                mkdirat(parent, name, Mode::from_bits_truncate(0o700)).map_err(|source| {
431                    CapabilityError::Io {
432                        path: current_path.join(name),
433                        source: source.into(),
434                    }
435                })?;
436                let fd = openat(parent, name, flags, Mode::empty()).map_err(|source| {
437                    CapabilityError::Io {
438                        path: current_path.join(name),
439                        source: source.into(),
440                    }
441                })?;
442                File::from(fd)
443            }
444            Err(source) => {
445                return Err(CapabilityError::Io {
446                    path: current_path.join(name),
447                    source: source.into(),
448                });
449            }
450        };
451        current_path.push(name);
452        handle = Some(opened);
453    }
454
455    handle.ok_or_else(|| CapabilityError::InvalidRelativePath {
456        path: relative.to_path_buf(),
457    })
458}
459
460#[cfg(unix)]
461fn read_file_bounded_unix(
462    directory: &File,
463    root: &Path,
464    relative: &Path,
465    max_bytes: usize,
466) -> Result<Vec<u8>, CapabilityError> {
467    use nix::fcntl::{OFlag, openat};
468    use nix::sys::stat::{Mode, SFlag, fstat};
469
470    let (parent, file_name) = split_relative(relative)?;
471    let parent_path = root.join(parent);
472    let parent_dir = descend_unix(directory, root, parent, false)?;
473    let flags = OFlag::O_RDONLY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
474    let fd = match openat(parent_dir, file_name, flags, Mode::empty()) {
475        Ok(fd) => fd,
476        Err(source) => {
477            let path = parent_path.join(file_name);
478            if fs::symlink_metadata(&path)
479                .is_ok_and(|metadata| is_symlink_or_reparse_point(&metadata))
480            {
481                return Err(CapabilityError::Symlink { path });
482            }
483            return Err(CapabilityError::Io {
484                path,
485                source: source.into(),
486            });
487        }
488    };
489    let metadata = fstat(&fd).map_err(|source| CapabilityError::Io {
490        path: parent_path.join(file_name),
491        source: source.into(),
492    })?;
493    if !SFlag::from_bits_truncate(metadata.st_mode).contains(SFlag::S_IFREG) {
494        return Err(CapabilityError::NotRegularFile {
495            path: parent_path.join(file_name),
496        });
497    }
498    let size = usize::try_from(metadata.st_size).map_err(|_| CapabilityError::TooLarge {
499        path: parent_path.join(file_name),
500        limit: max_bytes,
501    })?;
502    if size > max_bytes {
503        return Err(CapabilityError::TooLarge {
504            path: parent_path.join(file_name),
505            limit: max_bytes,
506        });
507    }
508    let file = File::from(fd);
509    read_file_to_end_bounded(file, &parent_path.join(file_name), max_bytes)
510}
511
512fn read_file_to_end_bounded(
513    mut reader: impl Read,
514    path: &Path,
515    max_bytes: usize,
516) -> Result<Vec<u8>, CapabilityError> {
517    let mut buffer = Vec::new();
518    let mut chunk = [0_u8; 8 * 1024];
519    loop {
520        let read = reader
521            .read(&mut chunk)
522            .map_err(|source| CapabilityError::Io {
523                path: path.to_path_buf(),
524                source,
525            })?;
526        if read == 0 {
527            break;
528        }
529        if buffer.len() + read > max_bytes {
530            return Err(CapabilityError::TooLarge {
531                path: path.to_path_buf(),
532                limit: max_bytes,
533            });
534        }
535        buffer.extend_from_slice(&chunk[..read]);
536    }
537    Ok(buffer)
538}
539
540#[cfg(unix)]
541fn atomic_write_unix(
542    parent: &File,
543    parent_path: &Path,
544    file_name: &OsStr,
545    bytes: &[u8],
546) -> Result<(), CapabilityError> {
547    use std::time::{SystemTime, UNIX_EPOCH};
548
549    use nix::fcntl::{OFlag, openat, renameat};
550    use nix::sys::stat::Mode;
551    use nix::unistd::{UnlinkatFlags, unlinkat};
552
553    let stamp = SystemTime::now()
554        .duration_since(UNIX_EPOCH)
555        .map_err(|_| CapabilityError::Io {
556            path: parent_path.join(file_name),
557            source: std::io::Error::other("system clock is earlier than the Unix epoch"),
558        })?;
559    let temp_name = format!(
560        ".{}.tmp.code-system-graph.{}-{}",
561        file_name.to_string_lossy(),
562        stamp.as_secs(),
563        stamp.subsec_nanos()
564    );
565    let create_flags =
566        OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_EXCL | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
567    let fd = openat(
568        parent,
569        temp_name.as_str(),
570        create_flags,
571        Mode::from_bits_truncate(0o600),
572    )
573    .map_err(|source| CapabilityError::Io {
574        path: parent_path.join(&temp_name),
575        source: source.into(),
576    })?;
577    let mut file = File::from(fd);
578    file.write_all(bytes)
579        .map_err(|source| CapabilityError::Io {
580            path: parent_path.join(&temp_name),
581            source,
582        })?;
583    file.sync_all().map_err(|source| CapabilityError::Io {
584        path: parent_path.join(&temp_name),
585        source,
586    })?;
587    if let Err(source) = renameat(parent, temp_name.as_str(), parent, file_name) {
588        let _ = unlinkat(parent, temp_name.as_str(), UnlinkatFlags::NoRemoveDir);
589        return Err(CapabilityError::Io {
590            path: parent_path.join(file_name),
591            source: source.into(),
592        });
593    }
594    Ok(())
595}
596
597#[cfg(unix)]
598fn remove_file_unix(
599    parent: &File,
600    parent_path: &Path,
601    file_name: &OsStr,
602) -> Result<bool, CapabilityError> {
603    use nix::unistd::{UnlinkatFlags, unlinkat};
604
605    match unlinkat(parent, file_name, UnlinkatFlags::NoRemoveDir) {
606        Ok(()) => Ok(true),
607        Err(nix::errno::Errno::ENOENT) => Ok(false),
608        Err(source) => Err(CapabilityError::Io {
609            path: parent_path.join(file_name),
610            source: source.into(),
611        }),
612    }
613}
614
615#[cfg(not(unix))]
616fn read_file_bounded_portable(
617    root: &Path,
618    relative: &Path,
619    max_bytes: usize,
620) -> Result<Vec<u8>, CapabilityError> {
621    let path = root.join(relative);
622    let metadata = fs::symlink_metadata(&path).map_err(|source| CapabilityError::Io {
623        path: path.clone(),
624        source,
625    })?;
626    if is_symlink_or_reparse_point(&metadata) {
627        return Err(CapabilityError::Symlink { path });
628    }
629    if !metadata.is_file() {
630        return Err(CapabilityError::NotRegularFile { path });
631    }
632    let size = metadata.len() as usize;
633    if size > max_bytes {
634        return Err(CapabilityError::TooLarge {
635            path,
636            limit: max_bytes,
637        });
638    }
639    let mut file = fs::File::open(&path).map_err(|source| CapabilityError::Io { path, source })?;
640    read_file_to_end_bounded(file, &path, max_bytes)
641}
642
643#[cfg(not(unix))]
644fn atomic_write_portable(
645    root: &Path,
646    relative: &Path,
647    bytes: &[u8],
648) -> Result<(), CapabilityError> {
649    use atomic_write_file::AtomicWriteFile;
650
651    let (parent, file_name) = split_relative(relative)?;
652    let parent_path = walk_directory_chain(root, parent, true)?;
653    let path = parent_path.join(file_name);
654    if let Ok(metadata) = fs::symlink_metadata(&path) {
655        if is_symlink_or_reparse_point(&metadata) {
656            return Err(CapabilityError::Symlink { path: path.clone() });
657        }
658        if metadata.is_dir() {
659            return Err(CapabilityError::NotRegularFile { path });
660        }
661    }
662    let mut destination = AtomicWriteFile::open(&path).map_err(|source| CapabilityError::Io {
663        path: path.clone(),
664        source,
665    })?;
666    destination
667        .write_all(bytes)
668        .and_then(|()| destination.sync_all())
669        .map_err(|source| CapabilityError::Io {
670            path: path.clone(),
671            source,
672        })?;
673    destination
674        .commit()
675        .map_err(|source| CapabilityError::Io { path, source })
676}
677
678#[cfg(not(unix))]
679fn remove_file_portable(root: &Path, relative: &Path) -> Result<bool, CapabilityError> {
680    let path = root.join(relative);
681    match fs::remove_file(&path) {
682        Ok(()) => Ok(true),
683        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
684        Err(source) => Err(CapabilityError::Io { path, source }),
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use std::io::{Cursor, Read};
691    use std::path::Path;
692
693    use super::{CapabilityDir, CapabilityError, MAX_REPOSITORY_CONFIG_BYTES};
694
695    struct ChunkedReader<R> {
696        inner: R,
697        chunk_size: usize,
698    }
699
700    impl<R: Read> Read for ChunkedReader<R> {
701        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
702            let limit = buf.len().min(self.chunk_size);
703            self.inner.read(&mut buf[..limit])
704        }
705    }
706
707    #[test]
708    fn walk_directory_chain_should_reject_intermediate_symlink()
709    -> Result<(), Box<dyn std::error::Error>> {
710        let temporary = tempfile::tempdir()?;
711        let root = temporary.path().join("repo");
712        let outside = temporary.path().join("outside");
713        std::fs::create_dir_all(&root)?;
714        std::fs::create_dir_all(&outside)?;
715        #[cfg(unix)]
716        std::os::unix::fs::symlink(&outside, root.join(".code-system-graph"))?;
717        #[cfg(windows)]
718        std::os::windows::fs::symlink_dir(&outside, root.join(".code-system-graph"))?;
719
720        let result =
721            super::walk_directory_chain(&root, Path::new(".code-system-graph/hooks"), true);
722
723        assert!(matches!(result, Err(CapabilityError::Symlink { .. })));
724        assert!(!outside.join("hooks").exists());
725        Ok(())
726    }
727
728    #[test]
729    fn capability_dir_should_reject_symlinked_config() -> Result<(), Box<dyn std::error::Error>> {
730        let temporary = tempfile::tempdir()?;
731        let checkout = temporary.path().join("checkout");
732        let outside = temporary.path().join("outside.yaml");
733        std::fs::create_dir_all(&checkout)?;
734        std::fs::write(&outside, "version: 1\n")?;
735        #[cfg(unix)]
736        std::os::unix::fs::symlink(&outside, checkout.join(".code-system-graph.yaml"))?;
737        #[cfg(windows)]
738        std::os::windows::fs::symlink_file(&outside, checkout.join(".code-system-graph.yaml"))?;
739
740        let root = CapabilityDir::open(&checkout)?;
741        let result = root.read_utf8_file_bounded(
742            Path::new(".code-system-graph.yaml"),
743            MAX_REPOSITORY_CONFIG_BYTES,
744        );
745        assert!(
746            matches!(
747                result,
748                Err(CapabilityError::Symlink { .. }
749                    | CapabilityError::OutsideRoot { .. }
750                    | CapabilityError::NotRegularFile { .. })
751            ),
752            "unexpected result: {result:?}"
753        );
754        Ok(())
755    }
756
757    #[test]
758    fn capability_dir_should_reject_oversized_config() -> Result<(), Box<dyn std::error::Error>> {
759        let checkout = tempfile::tempdir()?;
760        std::fs::write(
761            checkout.path().join(".code-system-graph.yaml"),
762            "x".repeat(MAX_REPOSITORY_CONFIG_BYTES + 1),
763        )?;
764        let root = CapabilityDir::open(checkout.path())?;
765        let result = root.read_utf8_file_bounded(
766            Path::new(".code-system-graph.yaml"),
767            MAX_REPOSITORY_CONFIG_BYTES,
768        );
769        assert!(matches!(result, Err(CapabilityError::TooLarge { .. })));
770        Ok(())
771    }
772
773    #[test]
774    fn capability_dir_should_treat_missing_parent_as_absent_on_remove()
775    -> Result<(), Box<dyn std::error::Error>> {
776        let checkout = tempfile::tempdir()?;
777        let root = CapabilityDir::open(checkout.path())?;
778        let removed =
779            root.remove_file_if_exists(Path::new(".code-system-graph/hooks/state.json"))?;
780        assert!(!removed);
781        Ok(())
782    }
783
784    #[test]
785    fn read_file_to_end_bounded_should_survive_short_reads()
786    -> Result<(), Box<dyn std::error::Error>> {
787        let payload = b"version: 1\n".repeat(2_048);
788        let reader = ChunkedReader {
789            inner: Cursor::new(payload.clone()),
790            chunk_size: 13,
791        };
792        let read =
793            super::read_file_to_end_bounded(reader, Path::new("config.yaml"), payload.len())?;
794        assert_eq!(read, payload);
795        Ok(())
796    }
797
798    #[test]
799    fn read_file_to_end_bounded_should_reject_overflow_after_short_reads() {
800        let payload = vec![b'x'; MAX_REPOSITORY_CONFIG_BYTES + 64];
801        let reader = ChunkedReader {
802            inner: Cursor::new(payload),
803            chunk_size: 17,
804        };
805        let result = super::read_file_to_end_bounded(
806            reader,
807            Path::new("config.yaml"),
808            MAX_REPOSITORY_CONFIG_BYTES,
809        );
810        assert!(matches!(result, Err(CapabilityError::TooLarge { .. })));
811    }
812}