Skip to main content

allow_core/
capped_read.rs

1//! Bounded text-file reads for source-tree scanners and policy discovery.
2//!
3//! cargo-allow never executes project code, but it still reads tracked files
4//! whole for syntax and policy inspection. Without a byte ceiling, a multi-GB
5//! tracked file can OOM CI. These helpers fail closed on oversized files before
6//! allocating the full contents.
7
8use std::fs::{self, File};
9use std::io::{Read, Take};
10use std::path::Path;
11
12/// Maximum bytes cargo-allow will load from one source-tree text file.
13///
14/// Chosen to keep ordinary Rust sources and policy/docs readable while rejecting
15/// pathological tracked files that would force unbounded memory use.
16pub const SOURCE_FILE_READ_MAX_BYTES: u64 = 8 * 1024 * 1024;
17
18/// Why a capped text read failed.
19#[derive(Debug)]
20pub enum CappedReadError {
21    Io(std::io::Error),
22    Oversized { len: Option<u64>, limit: u64 },
23    NotUtf8(std::string::FromUtf8Error),
24}
25
26impl std::fmt::Display for CappedReadError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            Self::Io(err) => write!(f, "{err}"),
30            Self::Oversized {
31                len: Some(len),
32                limit,
33            } => {
34                write!(
35                    f,
36                    "file is {len} bytes, which exceeds the {limit}-byte source-read limit"
37                )
38            }
39            Self::Oversized { len: None, limit } => {
40                write!(f, "file exceeds the {limit}-byte source-read limit")
41            }
42            Self::NotUtf8(err) => write!(f, "file is not valid UTF-8: {err}"),
43        }
44    }
45}
46
47impl std::error::Error for CappedReadError {
48    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
49        match self {
50            Self::Io(err) => Some(err),
51            Self::NotUtf8(err) => Some(err),
52            Self::Oversized { .. } => None,
53        }
54    }
55}
56
57impl CappedReadError {
58    pub fn is_oversized(&self) -> bool {
59        matches!(self, Self::Oversized { .. })
60    }
61}
62
63/// Read a UTF-8 text file only when its size is within [`SOURCE_FILE_READ_MAX_BYTES`].
64pub fn read_text_file_capped(path: &Path) -> Result<String, CappedReadError> {
65    read_text_file_capped_with_limit(path, SOURCE_FILE_READ_MAX_BYTES)
66}
67
68/// Read a UTF-8 text file only when its size is within `limit` bytes.
69///
70/// Uses `symlink_metadata` for an early regular-file size check, then opens the
71/// path and reads through a `Take` so symlink targets and TOCTOU races still
72/// cannot allocate unbounded buffers.
73pub fn read_text_file_capped_with_limit(
74    path: &Path,
75    limit: u64,
76) -> Result<String, CappedReadError> {
77    match fs::symlink_metadata(path) {
78        Ok(meta) => {
79            if meta.file_type().is_file() && meta.len() > limit {
80                return Err(CappedReadError::Oversized {
81                    len: Some(meta.len()),
82                    limit,
83                });
84            }
85        }
86        Err(err) => return Err(CappedReadError::Io(err)),
87    }
88
89    let file = File::open(path).map_err(CappedReadError::Io)?;
90    let mut limited: Take<File> = file.take(limit.saturating_add(1));
91    let mut bytes = Vec::new();
92    limited
93        .read_to_end(&mut bytes)
94        .map_err(CappedReadError::Io)?;
95    if (bytes.len() as u64) > limit {
96        return Err(CappedReadError::Oversized { len: None, limit });
97    }
98    String::from_utf8(bytes).map_err(CappedReadError::NotUtf8)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use std::io::Write;
105    use std::time::{SystemTime, UNIX_EPOCH};
106
107    fn temp_path(label: &str) -> std::path::PathBuf {
108        let stamp = SystemTime::now()
109            .duration_since(UNIX_EPOCH)
110            .map(|d| d.as_nanos())
111            .unwrap_or(0);
112        std::env::temp_dir().join(format!(
113            "cargo-allow-capped-read-{label}-{}-{stamp}",
114            std::process::id()
115        ))
116    }
117
118    #[test]
119    fn reads_small_utf8_files() {
120        let path = temp_path("small");
121        fs::write(&path, "hello\n")
122            .unwrap_or_else(|err| std::panic::panic_any(format!("write small fixture: {err}")));
123        let text = read_text_file_capped_with_limit(&path, 64).unwrap_or_else(|err| {
124            std::panic::panic_any(format!("small read should succeed: {err}"))
125        });
126        assert_eq!(text, "hello\n");
127        let _ = fs::remove_file(&path);
128    }
129
130    #[test]
131    fn rejects_oversized_files_before_full_allocation() {
132        let path = temp_path("oversized");
133        let limit = 64u64;
134        let mut file = File::create(&path).unwrap_or_else(|err| {
135            std::panic::panic_any(format!("create oversized fixture: {err}"))
136        });
137        file.write_all(&vec![b'a'; (limit as usize) + 1])
138            .unwrap_or_else(|err| std::panic::panic_any(format!("write oversized: {err}")));
139        drop(file);
140
141        let err = read_text_file_capped_with_limit(&path, limit).unwrap_err();
142        assert!(err.is_oversized(), "expected oversized, got {err}");
143        let _ = fs::remove_file(&path);
144    }
145
146    #[test]
147    fn accepts_file_exactly_at_limit() {
148        let path = temp_path("exact");
149        let limit = 32u64;
150        fs::write(&path, vec![b'b'; limit as usize])
151            .unwrap_or_else(|err| std::panic::panic_any(format!("write exact fixture: {err}")));
152        let text = read_text_file_capped_with_limit(&path, limit).unwrap_or_else(|err| {
153            std::panic::panic_any(format!("exact-limit read should succeed: {err}"))
154        });
155        assert_eq!(text.len() as u64, limit);
156        let _ = fs::remove_file(&path);
157    }
158}