1use 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
14pub const MAX_REPOSITORY_CONFIG_BYTES: usize = 1024 * 1024;
16
17#[derive(Debug, Error)]
19pub enum CapabilityError {
20 #[error("failed to access `{path}`: {source}")]
22 Io {
23 path: PathBuf,
25 #[source]
27 source: std::io::Error,
28 },
29 #[error("`{path}` is a symbolic link or reparse point")]
31 Symlink {
32 path: PathBuf,
34 },
35 #[error("`{path}` is not a regular file")]
37 NotRegularFile {
38 path: PathBuf,
40 },
41 #[error("`{path}` is not a directory")]
43 NotDirectory {
44 path: PathBuf,
46 },
47 #[error("`{path}` escapes the authorized root `{root}`")]
49 OutsideRoot {
50 path: PathBuf,
52 root: PathBuf,
54 },
55 #[error("relative path `{path}` is invalid")]
57 InvalidRelativePath {
58 path: PathBuf,
60 },
61 #[error("`{path}` exceeds {limit} bytes")]
63 TooLarge {
64 path: PathBuf,
66 limit: usize,
68 },
69 #[error("`{path}` is not valid UTF-8")]
71 InvalidUtf8 {
72 path: PathBuf,
74 },
75}
76
77pub struct CapabilityDir {
79 root: PathBuf,
80 #[cfg(unix)]
81 directory: File,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum RegularFileEntry {
87 Absent,
89 Regular,
91}
92
93impl CapabilityDir {
94 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 #[must_use]
126 pub fn root(&self) -> &Path {
127 &self.root
128 }
129
130 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 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 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 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 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 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#[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(¤t) {
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(¤t).map_err(|source| CapabilityError::Io {
366 path: current.clone(),
367 source,
368 })?;
369 let metadata =
370 fs::symlink_metadata(¤t).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}