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(file: File, compression: CompressionFormat) -> Result<Box<dyn BufRead>, std::io::Error> {
95 Ok(match compression {
96 CompressionFormat::Gzip => Box::new(BufReader::new(GzDecoder::new(file))),
97 CompressionFormat::Deflate => Box::new(BufReader::new(DeflateDecoder::new(file))),
98 CompressionFormat::Zlib => Box::new(BufReader::new(ZlibDecoder::new(file))),
99 CompressionFormat::Brotli => Box::new(BufReader::new(Decompressor::new(file, 4096))),
100 CompressionFormat::Zstd => Box::new(BufReader::new(ZstdDecoder::new(file)?)),
101 CompressionFormat::None => Box::new(BufReader::new(file)),
102 })
103}
104
105#[cfg(not(feature = "compression"))]
106fn create_reader(file: File, compression: CompressionFormat) -> Result<Box<dyn BufRead>, std::io::Error> {
107 if compression != CompressionFormat::None {
108 return Ok(Box::new(BufReader::new(std::io::empty())));
111 }
112 Ok(Box::new(BufReader::new(file)))
113}
114
115fn check_header_line(mut reader: Box<dyn BufRead>) -> Result<bool, std::io::Error> {
117 let mut first_line = String::new();
118
119 match reader.read_line(&mut first_line) {
120 Ok(0) => Ok(false), Ok(_) => {
122 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&first_line) {
124 if let Some(obj) = value.as_object() {
125 if let Some((first_key, first_value)) = obj.iter().next() {
128 if first_key == "type" {
129 if let Some(type_str) = first_value.as_str() {
130 return Ok(type_str == "@peoplesgrocers/json-archive");
131 }
132 }
133 }
134 }
135 }
136 Ok(false)
137 }
138 Err(e) => Err(e),
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum CompressionFormat {
144 Gzip,
145 Deflate,
146 Zlib,
147 Brotli,
148 Zstd,
149 None,
150}
151
152pub fn detect_compression_format(path: &Path, bytes: &[u8]) -> CompressionFormat {
153 if bytes.len() < 4 {
154 return CompressionFormat::None;
155 }
156
157 if bytes[0] == 0x1f && bytes[1] == 0x8b {
159 return CompressionFormat::Gzip;
160 }
161
162 if bytes[0] == 0x78 && (bytes[1] == 0x01 || bytes[1] == 0x5e || bytes[1] == 0x9c || bytes[1] == 0xda) {
164 return CompressionFormat::Zlib;
165 }
166
167 if bytes.len() >= 4 && bytes[0] == 0x28 && bytes[1] == 0xb5 && bytes[2] == 0x2f && bytes[3] == 0xfd {
169 return CompressionFormat::Zstd;
170 }
171
172 if let Some(ext) = path.extension() {
174 let ext_str = ext.to_string_lossy();
175 if ext_str == "br" || path.to_string_lossy().contains(".br.") {
176 return CompressionFormat::Brotli;
177 }
178 if ext_str == "deflate" {
179 return CompressionFormat::Deflate;
180 }
181 }
182
183 CompressionFormat::None
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use std::io::Write;
190 use tempfile::NamedTempFile;
191
192 #[test]
193 fn test_detect_by_json_archive_extension() -> Result<(), Box<dyn std::error::Error>> {
194 let mut temp_file = NamedTempFile::with_suffix(".json.archive")?;
195 writeln!(temp_file, r#"{{"some": "json"}}"#)?;
196 temp_file.flush()?;
197
198 assert!(is_json_archive(temp_file.path())?);
199 Ok(())
200 }
201
202 #[test]
203 fn test_detect_by_type_field() -> Result<(), Box<dyn std::error::Error>> {
204 let mut temp_file = NamedTempFile::with_suffix(".weird-extension")?;
205 writeln!(
206 temp_file,
207 r#"{{"type":"@peoplesgrocers/json-archive","version":1}}"#
208 )?;
209 temp_file.flush()?;
210
211 assert!(is_json_archive(temp_file.path())?);
212 Ok(())
213 }
214
215 #[test]
216 fn test_detect_by_type_field_with_tmp_extension() -> Result<(), Box<dyn std::error::Error>> {
217 let mut temp_file = NamedTempFile::with_suffix(".json.tmp")?;
218 writeln!(
219 temp_file,
220 r#"{{"type":"@peoplesgrocers/json-archive","version":1}}"#
221 )?;
222 temp_file.flush()?;
223
224 assert!(is_json_archive(temp_file.path())?);
225 Ok(())
226 }
227
228 #[test]
229 fn test_not_archive_regular_json() -> Result<(), Box<dyn std::error::Error>> {
230 let mut temp_file = NamedTempFile::with_suffix(".json")?;
231 writeln!(temp_file, r#"{{"some": "json"}}"#)?;
232 temp_file.flush()?;
233
234 assert!(!is_json_archive(temp_file.path())?);
235 Ok(())
236 }
237
238 #[test]
239 fn test_not_archive_wrong_type_field() -> Result<(), Box<dyn std::error::Error>> {
240 let mut temp_file = NamedTempFile::with_suffix(".tmp")?;
241 writeln!(temp_file, r#"{{"type":"something-else","version":1}}"#)?;
242 temp_file.flush()?;
243
244 assert!(!is_json_archive(temp_file.path())?);
245 Ok(())
246 }
247
248 #[test]
249 fn test_not_archive_type_not_first_field() -> Result<(), Box<dyn std::error::Error>> {
250 let mut temp_file = NamedTempFile::with_suffix(".tmp")?;
251 writeln!(
253 temp_file,
254 r#"{{"version":1,"zzz":"@peoplesgrocers/json-archive"}}"#
255 )?;
256 temp_file.flush()?;
257
258 assert!(!is_json_archive(temp_file.path())?);
260 Ok(())
261 }
262
263 #[test]
264 fn test_not_archive_empty_file() -> Result<(), Box<dyn std::error::Error>> {
265 let temp_file = NamedTempFile::with_suffix(".json")?;
266
267 assert!(!is_json_archive(temp_file.path())?);
268 Ok(())
269 }
270
271 #[test]
272 fn test_not_archive_invalid_json() -> Result<(), Box<dyn std::error::Error>> {
273 let mut temp_file = NamedTempFile::with_suffix(".tmp")?;
274 writeln!(temp_file, "not valid json")?;
275 temp_file.flush()?;
276
277 assert!(!is_json_archive(temp_file.path())?);
278 Ok(())
279 }
280}