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