Skip to main content

fluidattacks_blends/
content.rs

1//! Source content and loading it from disk.
2
3use std::fs;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6
7use crate::language::{Language, LanguageExt};
8
9pub mod custom_parsers;
10
11pub const MAX_FILE_SIZE: u64 = 1024 * 500;
12
13/// A source file's bytes, decoded text, detected language and path.
14#[derive(Clone, PartialEq, Eq, Debug)]
15#[non_exhaustive]
16pub struct Content {
17    /// The file's raw bytes, after any Helm-template fixup.
18    pub bytes: Vec<u8>,
19    /// `bytes`, lossily decoded as UTF-8.
20    pub text: String,
21    /// The language detected from the file's extension.
22    pub language: Language,
23    /// The file's path, as given to [`Content::from_path`].
24    pub path: PathBuf,
25}
26
27enum LoadError {
28    TooLarge,
29    Io,
30}
31
32fn read_within_size_limit(path: &Path, max_size: u64) -> Result<Vec<u8>, LoadError> {
33    let file = fs::File::open(path).map_err(|_| LoadError::Io)?;
34
35    if file.metadata().map_err(|_| LoadError::Io)?.len() > max_size {
36        return Err(LoadError::TooLarge);
37    }
38
39    let mut buf = Vec::new();
40    file.take(max_size.saturating_add(1))
41        .read_to_end(&mut buf)
42        .map_err(|_| LoadError::Io)?;
43
44    if u64::try_from(buf.len()).unwrap_or(u64::MAX) > max_size {
45        return Err(LoadError::TooLarge);
46    }
47
48    Ok(buf)
49}
50
51impl Content {
52    /// Loads and language-detects the file at `path`, applying any Helm
53    /// fixup and custom parser transform its content needs.
54    ///
55    /// Returns `None` for an unsupported extension, a file over `max_size`
56    /// bytes (defaults to 500 KiB), or one that cannot be read.
57    #[must_use]
58    pub fn from_path(path: &Path, max_size: Option<u64>) -> Option<Self> {
59        let max_size = max_size.unwrap_or(MAX_FILE_SIZE);
60
61        let Some(language) = Language::from_path(path) else {
62            tracing::warn!(path = %path.display(), "skipping file with unsupported extension");
63            return None;
64        };
65
66        let bytes = match read_within_size_limit(path, max_size) {
67            Ok(bytes) => bytes,
68            Err(LoadError::TooLarge) => {
69                tracing::warn!(path = %path.display(), "file too large, ignoring");
70                return None;
71            }
72            Err(LoadError::Io) => {
73                tracing::warn!(path = %path.display(), "unable to read file");
74                return None;
75            }
76        };
77
78        let text = String::from_utf8_lossy(&bytes).into_owned();
79
80        let content = Self {
81            bytes,
82            text,
83            language,
84            path: path.to_path_buf(),
85        };
86
87        custom_parsers::for_language(language)
88            .filter(|cp| cp.validate(&content))
89            .find_map(|cp| cp.transform(&content))
90            .or(Some(content))
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::Content;
97    use crate::language::Language;
98    use std::fs;
99    use std::path::{Path, PathBuf};
100
101    const HELM_JSON: &str = concat!(
102        r#"{"apiVersion": "v1", "kind": "Deployment", "metadata": {"name": "test"}, "#,
103        r#""spec": {"replicas": {{ .Values.replicas }}}}"#
104    );
105
106    const HELM_YAML: &str = concat!(
107        "apiVersion: v1\n",
108        "kind: Deployment\n",
109        "metadata:\n",
110        "  name: test\n",
111        "spec:\n",
112        "  replicas: {{ .Values.replicas }}\n",
113    );
114
115    fn write_temp(name: &str, bytes: &[u8]) -> (tempfile::TempDir, PathBuf) {
116        let dir = tempfile::tempdir().unwrap();
117        let path = dir.path().join(name);
118        fs::write(&path, bytes).unwrap();
119        (dir, path)
120    }
121
122    #[test]
123    fn loads_supported_file() {
124        let (_dir, path) = write_temp("module.java", b"class A {}");
125        let content = Content::from_path(&path, None).unwrap();
126
127        assert_eq!(content.language, Language::Java);
128        assert_eq!(content.text, "class A {}");
129        assert_eq!(content.bytes, b"class A {}".to_vec());
130        assert_eq!(content.path, path);
131    }
132
133    #[test]
134    fn loads_empty_file() {
135        let (_dir, path) = write_temp("empty.java", b"");
136        let content = Content::from_path(&path, None).unwrap();
137
138        assert_eq!(content.text, "");
139        assert!(content.bytes.is_empty());
140    }
141
142    #[test]
143    fn loads_multiline_content() {
144        let (_dir, path) = write_temp("multi.java", b"line1\nline2\nline3\n");
145        let content = Content::from_path(&path, None).unwrap();
146
147        assert_eq!(content.text, "line1\nline2\nline3\n");
148    }
149
150    #[test]
151    fn loads_special_characters() {
152        let source = "print('ñáéíóú中文🚀')";
153        let (_dir, path) = write_temp("special.py", source.as_bytes());
154        let content = Content::from_path(&path, None).unwrap();
155
156        assert_eq!(content.text, source);
157        assert_eq!(content.bytes, source.as_bytes().to_vec());
158    }
159
160    #[test]
161    fn rejects_unsupported_extension() {
162        let (_dir, path) = write_temp("a.unknown", b"whatever");
163        assert!(Content::from_path(&path, None).is_none());
164    }
165
166    #[test]
167    fn rejects_missing_file() {
168        let dir = tempfile::tempdir().unwrap();
169        let path = dir.path().join("missing.java");
170        assert!(Content::from_path(&path, None).is_none());
171    }
172
173    #[test]
174    fn respects_custom_max_size() {
175        let (_dir, path) = write_temp("a.java", b"class A {}");
176
177        assert!(Content::from_path(&path, Some(4)).is_none());
178        assert!(Content::from_path(&path, Some(1024)).is_some());
179    }
180
181    #[test]
182    fn json_without_helm_template_is_unchanged() {
183        let (_dir, path) = write_temp("config.json", br#"{"key": "value"}"#);
184        let content = Content::from_path(&path, None).unwrap();
185
186        assert_eq!(content.language, Language::Json);
187        assert_eq!(content.text, r#"{"key": "value"}"#);
188    }
189
190    #[test]
191    fn yaml_without_helm_template_is_unchanged() {
192        let (_dir, path) = write_temp("config.yaml", b"key: value\nother: data");
193        let content = Content::from_path(&path, None).unwrap();
194
195        assert_eq!(content.language, Language::Yaml);
196        assert_eq!(content.text, "key: value\nother: data");
197    }
198
199    #[test]
200    fn json_helm_template_outside_templates_dir_is_unchanged() {
201        let (_dir, path) = write_temp("deployment.json", HELM_JSON.as_bytes());
202        let content = Content::from_path(&path, None).unwrap();
203
204        assert_eq!(content.language, Language::Json);
205        assert_eq!(content.text, HELM_JSON);
206    }
207
208    #[test]
209    fn yaml_helm_template_outside_templates_dir_is_unchanged() {
210        let (_dir, path) = write_temp("deployment.yaml", HELM_YAML.as_bytes());
211        let content = Content::from_path(&path, None).unwrap();
212
213        assert_eq!(content.language, Language::Yaml);
214        assert_eq!(content.text, HELM_YAML);
215    }
216
217    #[test]
218    fn json_helm_template_in_templates_dir_is_unchanged() {
219        let dir = tempfile::tempdir().unwrap();
220        let templates = dir.path().join("templates");
221        fs::create_dir(&templates).unwrap();
222        let path = templates.join("deployment.json");
223        fs::write(&path, HELM_JSON).unwrap();
224
225        let content = Content::from_path(&path, None).unwrap();
226
227        assert_eq!(content.language, Language::Json);
228        assert_eq!(content.text, HELM_JSON);
229    }
230
231    #[test]
232    fn yaml_helm_template_in_templates_dir_is_unchanged() {
233        let dir = tempfile::tempdir().unwrap();
234        let templates = dir.path().join("templates");
235        fs::create_dir(&templates).unwrap();
236        let path = templates.join("deployment.yaml");
237        fs::write(&path, HELM_YAML).unwrap();
238
239        let content = Content::from_path(&path, None).unwrap();
240
241        assert_eq!(content.language, Language::Yaml);
242        assert_eq!(content.text, HELM_YAML);
243    }
244
245    #[test]
246    fn fixed_output_matches_python_results() {
247        let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/data/helm_parser");
248        let outputs = base.join("outputs");
249        fs::create_dir_all(&outputs).unwrap();
250
251        let mut entries: Vec<_> = fs::read_dir(base.join("templates"))
252            .unwrap()
253            .filter_map(|entry| entry.ok().map(|entry| entry.path()))
254            .filter(|path| path.is_file())
255            .collect();
256        entries.sort();
257
258        let mut mismatches: Vec<String> = Vec::new();
259        for path in entries {
260            let name = path.file_name().unwrap().to_string_lossy().into_owned();
261            let rust = Content::from_path(&path, None).unwrap().text;
262            fs::write(outputs.join(&name), &rust).unwrap();
263
264            let expected = fs::read_to_string(base.join("results").join(&name)).unwrap();
265            if rust != expected {
266                mismatches.push(name);
267            }
268        }
269
270        assert!(
271            mismatches.is_empty(),
272            "rust fixed output diverges from python results for: {mismatches:?}"
273        );
274    }
275}