json_archive/
detection.rs1use std::fs::File;
34use std::io::{BufRead, BufReader, Read};
35use std::path::Path;
36
37#[cfg(feature = "compression")]
38use brotli::Decompressor;
39#[cfg(feature = "compression")]
40use flate2::read::{DeflateDecoder, GzDecoder, ZlibDecoder};
41#[cfg(feature = "compression")]
42use zstd::stream::read::Decoder as ZstdDecoder;
43
44pub fn is_json_archive<P: AsRef<Path>>(path: P) -> Result<bool, std::io::Error> {
60 let path = path.as_ref();
61
62 if let Some(filename) = path.file_name() {
64 if let Some(filename_str) = filename.to_str() {
65 if filename_str.ends_with(".json.archive")
67 || filename_str.ends_with(".json.archive.gz")
68 || filename_str.ends_with(".json.archive.br")
69 || filename_str.ends_with(".json.archive.zst")
70 || filename_str.ends_with(".json.archive.zlib")
71 {
72 return Ok(true);
73 }
74 }
75 }
76
77 let mut file = File::open(path)?;
79 let mut magic_bytes = [0u8; 4];
80 let bytes_read = file.read(&mut magic_bytes)?;
81 let compression = detect_compression_format(path, &magic_bytes[..bytes_read]);
82
83 file = File::open(path)?;
85
86 let reader: Box<dyn BufRead> = create_reader(file, compression)?;
88
89 check_header_line(reader)
90}
91
92#[cfg(feature = "compression")]
94fn create_reader(
95 file: File,
96 compression: CompressionFormat,
97) -> Result<Box<dyn BufRead>, std::io::Error> {
98 Ok(match compression {
99 CompressionFormat::Gzip => Box::new(BufReader::new(GzDecoder::new(file))),
100 CompressionFormat::Deflate => Box::new(BufReader::new(DeflateDecoder::new(file))),
101 CompressionFormat::Zlib => Box::new(BufReader::new(ZlibDecoder::new(file))),
102 CompressionFormat::Brotli => Box::new(BufReader::new(Decompressor::new(file, 4096))),
103 CompressionFormat::Zstd => Box::new(BufReader::new(ZstdDecoder::new(file)?)),
104 CompressionFormat::None => Box::new(BufReader::new(file)),
105 })
106}
107
108#[cfg(not(feature = "compression"))]
109fn create_reader(
110 file: File,
111 compression: CompressionFormat,
112) -> Result<Box<dyn BufRead>, std::io::Error> {
113 if compression != CompressionFormat::None {
114 return Ok(Box::new(BufReader::new(std::io::empty())));
117 }
118 Ok(Box::new(BufReader::new(file)))
119}
120
121fn check_header_line(mut reader: Box<dyn BufRead>) -> Result<bool, std::io::Error> {
123 let mut first_line = String::new();
124
125 match reader.read_line(&mut first_line) {
126 Ok(0) => Ok(false), Ok(_) => {
128 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&first_line) {
130 if let Some(obj) = value.as_object() {
131 if let Some((first_key, first_value)) = obj.iter().next() {
134 if first_key == "type" {
135 if let Some(type_str) = first_value.as_str() {
136 return Ok(type_str == "@peoplesgrocers/json-archive");
137 }
138 }
139 }
140 }
141 }
142 Ok(false)
143 }
144 Err(e) => Err(e),
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum CompressionFormat {
150 Gzip,
151 Deflate,
152 Zlib,
153 Brotli,
154 Zstd,
155 None,
156}
157
158impl std::fmt::Display for CompressionFormat {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 match self {
161 CompressionFormat::Gzip => write!(f, "gzip"),
162 CompressionFormat::Deflate => write!(f, "deflate"),
163 CompressionFormat::Zlib => write!(f, "zlib"),
164 CompressionFormat::Brotli => write!(f, "brotli"),
165 CompressionFormat::Zstd => write!(f, "zstd"),
166 CompressionFormat::None => write!(f, "none"),
167 }
168 }
169}
170
171pub fn detect_compression_format(path: &Path, bytes: &[u8]) -> CompressionFormat {
172 if bytes.len() < 4 {
173 return CompressionFormat::None;
174 }
175
176 if bytes[0] == 0x1f && bytes[1] == 0x8b {
178 return CompressionFormat::Gzip;
179 }
180
181 if bytes[0] == 0x78
183 && (bytes[1] == 0x01 || bytes[1] == 0x5e || bytes[1] == 0x9c || bytes[1] == 0xda)
184 {
185 return CompressionFormat::Zlib;
186 }
187
188 if bytes.len() >= 4
190 && bytes[0] == 0x28
191 && bytes[1] == 0xb5
192 && bytes[2] == 0x2f
193 && bytes[3] == 0xfd
194 {
195 return CompressionFormat::Zstd;
196 }
197
198 if let Some(ext) = path.extension() {
200 let ext_str = ext.to_string_lossy();
201 if ext_str == "br" || path.to_string_lossy().contains(".br.") {
202 return CompressionFormat::Brotli;
203 }
204 if ext_str == "deflate" {
205 return CompressionFormat::Deflate;
206 }
207 }
208
209 CompressionFormat::None
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use std::io::Write;
216 use tempfile::NamedTempFile;
217
218 #[test]
219 fn test_detect_by_json_archive_extension() -> Result<(), Box<dyn std::error::Error>> {
220 let mut temp_file = NamedTempFile::with_suffix(".json.archive")?;
221 writeln!(temp_file, r#"{{"some": "json"}}"#)?;
222 temp_file.flush()?;
223
224 assert!(is_json_archive(temp_file.path())?);
225 Ok(())
226 }
227
228 #[test]
229 fn test_detect_by_type_field() -> Result<(), Box<dyn std::error::Error>> {
230 let mut temp_file = NamedTempFile::with_suffix(".weird-extension")?;
231 writeln!(
232 temp_file,
233 r#"{{"type":"@peoplesgrocers/json-archive","version":1}}"#
234 )?;
235 temp_file.flush()?;
236
237 assert!(is_json_archive(temp_file.path())?);
238 Ok(())
239 }
240
241 #[test]
242 fn test_detect_by_type_field_with_tmp_extension() -> Result<(), Box<dyn std::error::Error>> {
243 let mut temp_file = NamedTempFile::with_suffix(".json.tmp")?;
244 writeln!(
245 temp_file,
246 r#"{{"type":"@peoplesgrocers/json-archive","version":1}}"#
247 )?;
248 temp_file.flush()?;
249
250 assert!(is_json_archive(temp_file.path())?);
251 Ok(())
252 }
253
254 #[test]
255 fn test_not_archive_regular_json() -> Result<(), Box<dyn std::error::Error>> {
256 let mut temp_file = NamedTempFile::with_suffix(".json")?;
257 writeln!(temp_file, r#"{{"some": "json"}}"#)?;
258 temp_file.flush()?;
259
260 assert!(!is_json_archive(temp_file.path())?);
261 Ok(())
262 }
263
264 #[test]
265 fn test_not_archive_wrong_type_field() -> Result<(), Box<dyn std::error::Error>> {
266 let mut temp_file = NamedTempFile::with_suffix(".tmp")?;
267 writeln!(temp_file, r#"{{"type":"something-else","version":1}}"#)?;
268 temp_file.flush()?;
269
270 assert!(!is_json_archive(temp_file.path())?);
271 Ok(())
272 }
273
274 #[test]
275 fn test_not_archive_type_not_first_field() -> Result<(), Box<dyn std::error::Error>> {
276 let mut temp_file = NamedTempFile::with_suffix(".tmp")?;
277 writeln!(
279 temp_file,
280 r#"{{"version":1,"zzz":"@peoplesgrocers/json-archive"}}"#
281 )?;
282 temp_file.flush()?;
283
284 assert!(!is_json_archive(temp_file.path())?);
286 Ok(())
287 }
288
289 #[test]
290 fn test_not_archive_empty_file() -> Result<(), Box<dyn std::error::Error>> {
291 let temp_file = NamedTempFile::with_suffix(".json")?;
292
293 assert!(!is_json_archive(temp_file.path())?);
294 Ok(())
295 }
296
297 #[test]
298 fn test_not_archive_invalid_json() -> Result<(), Box<dyn std::error::Error>> {
299 let mut temp_file = NamedTempFile::with_suffix(".tmp")?;
300 writeln!(temp_file, "not valid json")?;
301 temp_file.flush()?;
302
303 assert!(!is_json_archive(temp_file.path())?);
304 Ok(())
305 }
306}