Skip to main content

zstd_complete/encoding/
file_profile.rs

1//! File-name and sample based classification for compression hints.
2
3#[cfg(feature = "std")]
4use crate::io::Read;
5#[cfg(feature = "std")]
6use alloc::vec::Vec;
7#[cfg(feature = "std")]
8use std::path::Path;
9
10#[cfg(feature = "std")]
11use super::util::{likely_composer_lockfile_text, likely_incompressible, likely_lockfile_text};
12
13#[cfg(feature = "std")]
14const FILE_TYPE_SAMPLE_BYTES: usize = 32 * 1024;
15
16#[derive(Copy, Clone, Debug, Eq, PartialEq)]
17pub(crate) enum CompressionFileProfile {
18    None,
19    CargoLock,
20    ComposerLock,
21    SmallTextLockfile,
22    DependencyJsonLockfile,
23}
24
25impl CompressionFileProfile {
26    pub(crate) const fn internal_hint_code(self) -> u8 {
27        match self {
28            Self::None => 0,
29            Self::CargoLock => 1,
30            Self::ComposerLock => 2,
31            Self::SmallTextLockfile => 3,
32            Self::DependencyJsonLockfile => 4,
33        }
34    }
35
36    pub(crate) const fn from_internal_hint_code(code: u8) -> Self {
37        match code {
38            1 => Self::CargoLock,
39            2 => Self::ComposerLock,
40            3 => Self::SmallTextLockfile,
41            4 => Self::DependencyJsonLockfile,
42            _ => Self::None,
43        }
44    }
45}
46
47#[cfg(feature = "std")]
48mod known;
49
50#[cfg(feature = "std")]
51use known::{classify_file_name, file_name_matches_exact_or_prefixed};
52
53#[cfg(feature = "std")]
54fn likely_json_text_sample(data: &[u8]) -> bool {
55    if !sample_looks_text(data) {
56        return false;
57    }
58
59    let trimmed = data
60        .iter()
61        .skip_while(|&&byte| matches!(byte, b' ' | b'\t' | b'\n' | b'\r'))
62        .copied()
63        .collect::<Vec<u8>>();
64    let trimmed = trimmed.as_slice();
65    if !matches!(trimmed.first(), Some(b'{') | Some(b'[')) {
66        return false;
67    }
68
69    let window = &trimmed[..trimmed.len().min(2048)];
70    window.contains(&b':') && window.iter().filter(|&&byte| byte == b'"').count() >= 4
71}
72
73#[cfg(feature = "std")]
74fn sample_looks_text(data: &[u8]) -> bool {
75    if data.len() < 16 {
76        return false;
77    }
78
79    let step = (data.len() / 128).max(1);
80    let mut printable = 0usize;
81    let mut total = 0usize;
82    for idx in (0..data.len()).step_by(step).take(128) {
83        total += 1;
84        let byte = data[idx];
85        if byte == b'\n'
86            || byte == b'\r'
87            || byte == b'\t'
88            || byte == b' '
89            || byte.is_ascii_graphic()
90        {
91            printable += 1;
92        }
93    }
94
95    printable * 100 >= total * 85
96}
97
98#[cfg(feature = "std")]
99fn likely_dictionary_text_sample(data: &[u8]) -> bool {
100    let mut package_markers = 0usize;
101    let mut name_markers = 0usize;
102    let mut version_markers = 0usize;
103    let mut checksum_markers = 0usize;
104    let mut composer_packages = false;
105    let mut composer_require = 0usize;
106    let mut composer_reference = 0usize;
107
108    for line in data.split(|&byte| byte == b'\n').take(512) {
109        let trimmed = line
110            .iter()
111            .skip_while(|&&byte| matches!(byte, b' ' | b'\t'))
112            .copied()
113            .collect::<Vec<u8>>();
114        let trimmed = trimmed.as_slice();
115
116        if trimmed.starts_with(b"[[package]]") {
117            package_markers += 1;
118        } else if trimmed.starts_with(b"name = \"") || trimmed.starts_with(br#""name": ""#) {
119            name_markers += 1;
120        } else if trimmed.starts_with(b"version = \"") || trimmed.starts_with(br#""version": ""#) {
121            version_markers += 1;
122        } else if trimmed.starts_with(b"checksum = \"") {
123            checksum_markers += 1;
124        } else if trimmed.starts_with(br#""packages": ["#) {
125            composer_packages = true;
126        } else if trimmed.starts_with(br#""require": {"#) {
127            composer_require += 1;
128        } else if trimmed.starts_with(br#""reference": ""#) {
129            composer_reference += 1;
130        }
131
132        if package_markers >= 2
133            && name_markers >= 2
134            && version_markers >= 2
135            && checksum_markers >= 2
136        {
137            return true;
138        }
139        if composer_packages
140            && name_markers >= 2
141            && version_markers >= 2
142            && composer_require >= 2
143            && composer_reference >= 2
144        {
145            return true;
146        }
147    }
148
149    false
150}
151
152#[cfg(feature = "std")]
153fn likely_config_text_sample(data: &[u8]) -> bool {
154    if !sample_looks_text(data) {
155        return false;
156    }
157
158    let mut score = 0usize;
159    for line in data.split(|&byte| byte == b'\n').take(256) {
160        let line = line
161            .iter()
162            .skip_while(|&&byte| matches!(byte, b' ' | b'\t'))
163            .copied()
164            .collect::<Vec<u8>>();
165        let line = line.as_slice();
166        if line.is_empty() {
167            continue;
168        }
169
170        if (line.starts_with(b"[") && line.ends_with(b"]"))
171            || (line.starts_with(b"<") && line.contains(&b'>'))
172        {
173            score += 2;
174        } else if line.starts_with(b"- ") || line.starts_with(b"* ") {
175            score += 1;
176        } else if line.starts_with(b"FROM ")
177            || line.starts_with(b"RUN ")
178            || line.starts_with(b"ENV ")
179            || line.starts_with(b"COPY ")
180            || line.starts_with(b"WORKDIR ")
181            || line.starts_with(b"ENTRYPOINT ")
182            || line.starts_with(b"CMD ")
183        {
184            score += 2;
185        } else if (line.windows(2).any(|pair| pair == b": ")
186            && !line.windows(3).any(|pair| pair == b"://"))
187            || line.contains(&b'=')
188        {
189            score += 1;
190        }
191
192        if score >= 4 {
193            return true;
194        }
195    }
196
197    false
198}
199
200#[cfg(feature = "std")]
201fn likely_code_text_sample(data: &[u8]) -> bool {
202    if !sample_looks_text(data) {
203        return false;
204    }
205
206    let mut score = 0usize;
207    for line in data.split(|&byte| byte == b'\n').take(256) {
208        let line = line
209            .iter()
210            .skip_while(|&&byte| matches!(byte, b' ' | b'\t'))
211            .copied()
212            .collect::<Vec<u8>>();
213        let line = line.as_slice();
214        if line.is_empty() {
215            continue;
216        }
217
218        if line.ends_with(b";") || line.ends_with(b"{") || line.ends_with(b"}") {
219            score += 1;
220        }
221        if line
222            .windows(2)
223            .any(|pair| matches!(pair, b"::" | b"->" | b"=>" | b"//"))
224        {
225            score += 1;
226        }
227        if line.starts_with(b"fn ")
228            || line.starts_with(b"pub ")
229            || line.starts_with(b"let ")
230            || line.starts_with(b"const ")
231            || line.starts_with(b"var ")
232            || line.starts_with(b"def ")
233            || line.starts_with(b"class ")
234            || line.starts_with(b"import ")
235            || line.starts_with(b"package ")
236            || line.starts_with(b"use ")
237            || line.starts_with(b"#include")
238            || line.starts_with(b"<?php")
239        {
240            score += 2;
241        }
242
243        if score >= 4 {
244            return true;
245        }
246    }
247
248    false
249}
250
251#[cfg(feature = "std")]
252fn has_archive_signature(data: &[u8]) -> bool {
253    data.starts_with(b"PK\x03\x04")
254        || data.starts_with(b"PK\x05\x06")
255        || data.starts_with(b"PK\x07\x08")
256        || data.starts_with(&[0x1F, 0x8B])
257        || data.starts_with(b"BZh")
258        || data.starts_with(&[0xFD, b'7', b'z', b'X', b'Z', 0x00])
259        || data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD])
260        || data.starts_with(&[b'7', b'z', 0xBC, 0xAF, 0x27, 0x1C])
261        || data.starts_with(b"Rar!\x1A\x07\x00")
262        || data.starts_with(b"Rar!\x1A\x07\x01\x00")
263        || (data.len() > 262 && &data[257..262] == b"ustar")
264}
265
266#[cfg(feature = "std")]
267fn has_binary_signature(data: &[u8]) -> bool {
268    data.starts_with(b"\x7FELF")
269        || data.starts_with(b"MZ")
270        || data.starts_with(b"\0asm")
271        || data.starts_with(&[0xCA, 0xFE, 0xBA, 0xBE])
272        || data.starts_with(&[0xFE, 0xED, 0xFA, 0xCE])
273        || data.starts_with(&[0xFE, 0xED, 0xFA, 0xCF])
274        || data.starts_with(&[0xCE, 0xFA, 0xED, 0xFE])
275        || data.starts_with(&[0xCF, 0xFA, 0xED, 0xFE])
276        || data.starts_with(b"SQLite format 3\0")
277        || data.starts_with(b"%PDF-")
278}
279
280#[cfg(feature = "std")]
281fn compression_file_type_for_sample(sample: &[u8]) -> CompressionFileType {
282    if sample.is_empty() {
283        return CompressionFileType::Unknown;
284    }
285    if has_archive_signature(sample) {
286        return CompressionFileType::ArchiveLike;
287    }
288    if has_binary_signature(sample) {
289        return CompressionFileType::BinaryLike;
290    }
291    if likely_lockfile_text(sample)
292        || likely_composer_lockfile_text(sample)
293        || likely_dictionary_text_sample(sample)
294    {
295        return CompressionFileType::DictionaryText;
296    }
297    if likely_json_text_sample(sample) {
298        return CompressionFileType::JsonText;
299    }
300    if likely_config_text_sample(sample) {
301        return CompressionFileType::ConfigText;
302    }
303    if likely_code_text_sample(sample) {
304        return CompressionFileType::CodeText;
305    }
306    if likely_incompressible(sample) {
307        return CompressionFileType::BinaryLike;
308    }
309    CompressionFileType::Unknown
310}
311
312#[cfg(feature = "std")]
313pub(crate) fn read_file_type_sample<R: Read>(source: &mut R) -> Vec<u8> {
314    let mut sample = Vec::with_capacity(FILE_TYPE_SAMPLE_BYTES);
315    let mut buffer = [0u8; 4096];
316    while sample.len() < FILE_TYPE_SAMPLE_BYTES {
317        let wanted = (FILE_TYPE_SAMPLE_BYTES - sample.len()).min(buffer.len());
318        let read = source.read(&mut buffer[..wanted]).unwrap();
319        if read == 0 {
320            break;
321        }
322        sample.extend_from_slice(&buffer[..read]);
323    }
324    sample
325}
326
327/// Coarse file families used to choose the encoder's internal starting point.
328///
329/// This is intentionally small and stable. It is not an exposed bag of tuning knobs.
330#[derive(Copy, Clone, Debug, Eq, PartialEq)]
331pub enum CompressionFileType {
332    Unknown,
333    ArchiveLike,
334    BinaryLike,
335    CodeText,
336    ConfigText,
337    JsonText,
338    DictionaryText,
339}
340
341#[cfg(feature = "std")]
342pub fn compression_file_type_for_path(path: &Path) -> CompressionFileType {
343    let file_name = path
344        .file_name()
345        .and_then(|name| name.to_str())
346        .unwrap_or_default();
347    classify_file_name(&file_name.to_ascii_lowercase())
348}
349
350/// Classify using path first, then sample bytes if the path does not resolve to a known family.
351#[cfg(feature = "std")]
352pub fn compression_file_type_for_path_and_data(path: &Path, sample: &[u8]) -> CompressionFileType {
353    let by_path = compression_file_type_for_path(path);
354    if by_path != CompressionFileType::Unknown {
355        return by_path;
356    }
357    compression_file_type_for_sample(sample)
358}
359
360#[cfg(feature = "std")]
361pub(crate) fn compression_file_profile_for_path_and_data(
362    path: &Path,
363    sample: &[u8],
364) -> CompressionFileProfile {
365    let file_name = path
366        .file_name()
367        .and_then(|name| name.to_str())
368        .unwrap_or_default()
369        .to_ascii_lowercase();
370    if file_name_matches_exact_or_prefixed(&file_name, "cargo.lock") {
371        CompressionFileProfile::CargoLock
372    } else if file_name_matches_exact_or_prefixed(&file_name, "composer.lock") {
373        CompressionFileProfile::ComposerLock
374    } else if file_name_matches_exact_or_prefixed(&file_name, "poetry.lock")
375        || file_name_matches_exact_or_prefixed(&file_name, "pubspec.lock")
376    {
377        CompressionFileProfile::SmallTextLockfile
378    } else if file_name_matches_exact_or_prefixed(&file_name, "package-lock.json")
379        || file_name_matches_exact_or_prefixed(&file_name, "pipfile.lock")
380        || super::util::likely_dependency_json_lockfile_text(sample)
381    {
382        CompressionFileProfile::DependencyJsonLockfile
383    } else {
384        CompressionFileProfile::None
385    }
386}
387
388#[cfg(all(test, feature = "std"))]
389mod tests;