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