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