1use 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
12pub const MAX_REPOSITORY_CONFIG_BYTES: usize = 1024 * 1024;
14
15#[derive(Debug, Error)]
17pub enum CapabilityError {
18 #[error("failed to access `{path}`: {source}")]
20 Io {
21 path: PathBuf,
23 #[source]
25 source: std::io::Error,
26 },
27 #[error("`{path}` is a symbolic link or reparse point")]
29 Symlink {
30 path: PathBuf,
32 },
33 #[error("`{path}` is not a regular file")]
35 NotRegularFile {
36 path: PathBuf,
38 },
39 #[error("`{path}` is not a directory")]
41 NotDirectory {
42 path: PathBuf,
44 },
45 #[error("`{path}` escapes the authorized root `{root}`")]
47 OutsideRoot {
48 path: PathBuf,
50 root: PathBuf,
52 },
53 #[error("relative path `{path}` is invalid")]
55 InvalidRelativePath {
56 path: PathBuf,
58 },
59 #[error("`{path}` exceeds {limit} bytes")]
61 TooLarge {
62 path: PathBuf,
64 limit: usize,
66 },
67 #[error("`{path}` is not valid UTF-8")]
69 InvalidUtf8 {
70 path: PathBuf,
72 },
73}
74
75pub struct CapabilityDir {
77 root: PathBuf,
78 #[cfg(unix)]
79 directory: File,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum RegularFileEntry {
85 Absent,
87 Regular,
89}
90
91impl CapabilityDir {
92 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 #[must_use]
124 pub fn root(&self) -> &Path {
125 &self.root
126 }
127
128 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 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 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 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 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 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#[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(¤t) {
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(¤t).map_err(|source| CapabilityError::Io {
364 path: current.clone(),
365 source,
366 })?;
367 let metadata =
368 fs::symlink_metadata(¤t).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}