Skip to main content

readcon_core/
compression.rs

1//=============================================================================
2// Transparent compression support
3//=============================================================================
4
5use std::io::{self, Read};
6use std::path::Path;
7
8/// Detected compression format based on magic bytes.
9///
10/// `Zstd` is only constructed when the `zstd` Cargo feature is enabled.
11/// Builds without the feature treat `.zst` files as opaque bytes and
12/// return an error from [`read_file_contents`] indicating the feature
13/// is required.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Compression {
16    None,
17    Gzip,
18    /// zstd frame, magic `28 B5 2F FD`. Build with `--features zstd`.
19    Zstd,
20}
21
22/// Detect compression format from the first bytes of a file.
23///
24/// - `1f 8b` = gzip
25/// - `28 b5 2f fd` = zstd
26/// - Otherwise = uncompressed
27pub fn detect_compression(bytes: &[u8]) -> Compression {
28    if bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b {
29        return Compression::Gzip;
30    }
31    if bytes.len() >= 4
32        && bytes[0] == 0x28
33        && bytes[1] == 0xb5
34        && bytes[2] == 0x2f
35        && bytes[3] == 0xfd
36    {
37        return Compression::Zstd;
38    }
39    Compression::None
40}
41
42/// Detect compression format from a file extension.
43///
44/// Returns `Compression::Gzip` for `.gz`, `Compression::Zstd` for
45/// `.zst`, `Compression::None` otherwise.
46pub fn detect_compression_from_extension(path: &Path) -> Compression {
47    match path.extension().and_then(|e| e.to_str()) {
48        Some("gz") => Compression::Gzip,
49        Some("zst") => Compression::Zstd,
50        _ => Compression::None,
51    }
52}
53
54/// Size threshold below which we use `read_to_string` instead of mmap.
55const MMAP_THRESHOLD: u64 = 64 * 1024;
56
57/// Reads file contents, decompressing if needed.
58///
59/// Detection strategy:
60/// 1. Read the first 4 bytes to check magic bytes.
61/// 2. If gzip or zstd: decompress entire file to a String.
62/// 3. If uncompressed and < 64 KiB: read the rest into a String.
63/// 4. If uncompressed and >= 64 KiB: memory-mapped I/O.
64///
65/// The path is opened once and every byte comes from that handle, so a
66/// pipe works and a file replaced during the call is not mixed in.
67pub fn read_file_contents(path: &Path) -> Result<FileContents, Box<dyn std::error::Error>> {
68    read_file_contents_detected(path).map(|(contents, _)| contents)
69}
70
71/// [`read_file_contents`], also returning the compression it detected.
72pub fn read_file_contents_detected(
73    path: &Path,
74) -> Result<(FileContents, Compression), Box<dyn std::error::Error>> {
75    read_contents(path, true)
76}
77
78/// [`read_file_contents`] as one owned `String`. An uncompressed file is
79/// read into it at any size instead of being mapped, so the text is held
80/// once and later changes to the file do not reach it.
81pub fn read_file_text(path: &Path) -> Result<String, Box<dyn std::error::Error>> {
82    match read_contents(path, false)?.0 {
83        FileContents::Owned(text) => Ok(text),
84        FileContents::Mapped(_) => unreachable!("read_contents maps only when asked to"),
85    }
86}
87
88fn read_contents(
89    path: &Path,
90    map_large: bool,
91) -> Result<(FileContents, Compression), Box<dyn std::error::Error>> {
92    let file = std::fs::File::open(path)?;
93    let metadata = file.metadata()?;
94
95    // gzip needs 2 magic bytes and zstd needs 4. They stay in front of the
96    // rest of the stream, since a pipe cannot seek back.
97    let mut magic = Vec::with_capacity(4);
98    (&file).take(4).read_to_end(&mut magic)?;
99    let compression = detect_compression(&magic);
100    let mut stream = magic.as_slice().chain(&file);
101
102    let contents = match compression {
103        Compression::Gzip => {
104            // Every gzip member, in order (CON-compress.1, D16).
105            let mut decoder = flate2::read::MultiGzDecoder::new(stream);
106            let mut contents = String::new();
107            decoder.read_to_string(&mut contents)?;
108            FileContents::Owned(contents)
109        }
110        Compression::Zstd => {
111            #[cfg(feature = "zstd")]
112            {
113                let mut decoder = zstd::stream::read::Decoder::new(stream)?;
114                let mut contents = String::new();
115                decoder.read_to_string(&mut contents)?;
116                FileContents::Owned(contents)
117            }
118            #[cfg(not(feature = "zstd"))]
119            {
120                return Err(zstd_unsupported().into());
121            }
122        }
123        Compression::None if metadata.len() < MMAP_THRESHOLD => {
124            let mut bytes = Vec::with_capacity(metadata.len() as usize);
125            stream.read_to_end(&mut bytes)?;
126            let contents = String::from_utf8(bytes).map_err(|_| {
127                io::Error::new(
128                    io::ErrorKind::InvalidData,
129                    "stream did not contain valid UTF-8",
130                )
131            })?;
132            FileContents::Owned(contents)
133        }
134        Compression::None if map_large => {
135            let mmap = unsafe { memmap2::Mmap::map(&file)? };
136            FileContents::Mapped(mmap)
137        }
138        Compression::None => {
139            let mut bytes = Vec::with_capacity(metadata.len() as usize);
140            stream.read_to_end(&mut bytes)?;
141            // The message `as_str` gives for the same bytes when mapped.
142            FileContents::Owned(String::from_utf8(bytes).map_err(|e| e.utf8_error())?)
143        }
144    };
145    Ok((contents, compression))
146}
147
148#[cfg(not(feature = "zstd"))]
149fn zstd_unsupported() -> io::Error {
150    io::Error::new(
151        io::ErrorKind::Unsupported,
152        "zstd-compressed input detected; rebuild readcon-core with --features zstd",
153    )
154}
155
156/// The bytes of `path` as one stream, decompressed while it is read.
157///
158/// Compression is detected by magic bytes, as in [`read_file_contents`],
159/// and every gzip member is read in order.
160/// The only memory used is the decoder's own buffers, so it does not grow
161/// with the file.
162pub fn open_decoded(path: &Path) -> Result<Box<dyn Read + Send>, Box<dyn std::error::Error>> {
163    let mut file = std::fs::File::open(path)?;
164    let mut magic = Vec::with_capacity(4);
165    (&mut file).take(4).read_to_end(&mut magic)?;
166    let compression = detect_compression(&magic);
167    let stream = io::Cursor::new(magic).chain(file);
168    Ok(match compression {
169        Compression::Gzip => Box::new(flate2::read::MultiGzDecoder::new(stream)),
170        #[cfg(feature = "zstd")]
171        Compression::Zstd => Box::new(zstd::stream::read::Decoder::new(stream)?),
172        #[cfg(not(feature = "zstd"))]
173        Compression::Zstd => return Err(zstd_unsupported().into()),
174        Compression::None => Box::new(stream),
175    })
176}
177
178/// Holds file contents either as an owned String or a memory-mapped region.
179pub enum FileContents {
180    Owned(String),
181    Mapped(memmap2::Mmap),
182}
183
184impl FileContents {
185    pub fn as_str(&self) -> Result<&str, std::str::Utf8Error> {
186        match self {
187            FileContents::Owned(s) => Ok(s.as_str()),
188            FileContents::Mapped(m) => std::str::from_utf8(m),
189        }
190    }
191
192    pub fn as_bytes(&self) -> &[u8] {
193        match self {
194            FileContents::Owned(s) => s.as_bytes(),
195            FileContents::Mapped(m) => m,
196        }
197    }
198
199    /// Like [`Self::as_str`], but validates mapped files in newline-aligned
200    /// slices (in parallel with the `parallel` feature).
201    pub fn as_str_sliced(&self) -> Result<&str, std::str::Utf8Error> {
202        match self {
203            FileContents::Owned(s) => Ok(s.as_str()),
204            FileContents::Mapped(m) => crate::frame_index::validate_utf8_sliced(m),
205        }
206    }
207}
208
209/// Mapped bytes between two releases of the pages already read.
210const RELEASE_STEP: usize = 32 << 20;
211
212/// The text of one file, read from the front.
213///
214/// With `map`, an uncompressed file of 64 KiB or more stays mapped, and
215/// [`Self::release_before`] advises `MADV_DONTNEED` for the pages before the
216/// read position. On Linux that drops the file pages, so resident memory
217/// stays near [`RELEASE_STEP`] instead of the file size. macOS gets the same
218/// call, but its `MADV_DONTNEED` does not discard pages. Outside Unix the
219/// release is a no-op. On those systems the whole file can become resident.
220/// A mapped file is not a snapshot and must not change while it is read; a
221/// truncation can end the process with `SIGBUS`.
222pub struct ForwardText {
223    contents: FileContents,
224    released: usize,
225}
226
227impl ForwardText {
228    /// The text of `path`. Without `map` it is [`read_file_text`].
229    pub fn open(path: &Path, map: bool) -> Result<Self, Box<dyn std::error::Error>> {
230        let contents = if map {
231            read_file_contents(path)?
232        } else {
233            FileContents::Owned(read_file_text(path)?)
234        };
235        if let FileContents::Mapped(m) = &contents {
236            let mut start = 0;
237            while start < m.len() {
238                let target = (start + RELEASE_STEP).min(m.len());
239                let end = memchr::memchr(b'\n', &m[target..]).map_or(m.len(), |p| target + p + 1);
240                if crate::frame_index::validate_utf8_sliced(&m[start..end]).is_err() {
241                    std::str::from_utf8(m)?;
242                }
243                release(m, start, end)?;
244                start = end;
245            }
246        }
247        Ok(Self {
248            contents,
249            released: 0,
250        })
251    }
252
253    pub fn as_str(&self) -> &str {
254        match &self.contents {
255            FileContents::Owned(s) => s,
256            // SAFETY: `open` checked every byte, in slices that end after a
257            // newline. A mapped file must not change while mapped, as for
258            // every mapped reader in this crate.
259            FileContents::Mapped(m) => unsafe { std::str::from_utf8_unchecked(m) },
260        }
261    }
262
263    /// Release the mapped pages before byte `pos` once [`RELEASE_STEP`]
264    /// bytes have been read since the last release. A later read of them
265    /// maps them in again. A `pos` past the end counts as the end.
266    pub fn release_before(&mut self, pos: usize) -> io::Result<()> {
267        if let FileContents::Mapped(m) = &self.contents
268            && pos >= self.released + RELEASE_STEP
269        {
270            let pos = pos.min(m.len());
271            release(m, self.released, pos)?;
272            self.released = pos;
273        }
274        Ok(())
275    }
276}
277
278/// Advise `MADV_DONTNEED` for the whole 64 KiB blocks of the map that
279/// `start..end` reaches, cut at the map end.
280fn release(map: &memmap2::Mmap, start: usize, end: usize) -> io::Result<()> {
281    // A multiple of every Linux page size. The map starts on a page, so the
282    // range stays inside it, and the page that contains `end` stays mapped.
283    const BLOCK: usize = 64 * 1024;
284    let start = start & !(BLOCK - 1);
285    let end = end.min(map.len()) & !(BLOCK - 1);
286    if end <= start {
287        return Ok(());
288    }
289    // SAFETY: `start..end` lies inside the map, which is shared and read
290    // only, so dropped pages read back from the file.
291    #[cfg(unix)]
292    unsafe {
293        map.unchecked_advise_range(memmap2::UncheckedAdvice::DontNeed, start, end - start)?;
294    }
295    #[cfg(not(unix))]
296    let _ = map;
297    Ok(())
298}
299
300/// Creates a gzip-compressed writer wrapping a file at the given path.
301pub fn gzip_writer(path: &Path) -> io::Result<flate2::write::GzEncoder<std::fs::File>> {
302    let file = std::fs::File::create(path)?;
303    Ok(flate2::write::GzEncoder::new(
304        file,
305        flate2::Compression::default(),
306    ))
307}
308
309/// Creates a zstd-compressed writer wrapping a file at the given path.
310///
311/// Returns a finished writer ready for streaming line writes; the caller
312/// is responsible for dropping the writer to flush the final frame.
313/// Available only when the `zstd` Cargo feature is enabled.
314#[cfg(feature = "zstd")]
315pub fn zstd_writer(
316    path: &Path,
317) -> io::Result<zstd::stream::write::AutoFinishEncoder<'static, std::fs::File>> {
318    let file = std::fs::File::create(path)?;
319    // Level 3 is the zstd CLI default; balances ratio against speed.
320    let encoder = zstd::stream::write::Encoder::new(file, 3)?;
321    Ok(encoder.auto_finish())
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use std::io::Write;
328
329    fn text_of_at_least(len: usize) -> Vec<u8> {
330        let frame = include_bytes!("../resources/test/tiny_cuh2.con");
331        frame.repeat(len / frame.len() + 2)
332    }
333
334    fn large_text() -> Vec<u8> {
335        text_of_at_least(MMAP_THRESHOLD as usize)
336    }
337
338    fn mapped_text(path: &Path) -> Result<String, String> {
339        let contents = read_file_contents(path).map_err(|e| e.to_string())?;
340        contents
341            .as_str()
342            .map(str::to_owned)
343            .map_err(|e| e.to_string())
344    }
345
346    #[test]
347    fn file_text_matches_the_mapped_read() {
348        let dir = tempfile::tempdir().unwrap();
349        let small = dir.path().join("small.con");
350        std::fs::write(&small, include_bytes!("../resources/test/tiny_cuh2.con")).unwrap();
351        let large = dir.path().join("large.con");
352        std::fs::write(&large, large_text()).unwrap();
353        assert!(matches!(
354            read_file_contents(&large).unwrap(),
355            FileContents::Mapped(_)
356        ));
357        let gz = dir.path().join("large.con.gz");
358        let mut enc = gzip_writer(&gz).unwrap();
359        enc.write_all(&large_text()).unwrap();
360        enc.finish().unwrap();
361        for path in [&small, &large, &gz] {
362            let text = read_file_text(path).unwrap();
363            assert_eq!(Ok(text), mapped_text(path), "{}", path.display());
364        }
365    }
366
367    #[test]
368    fn file_text_reports_invalid_utf8_as_the_mapped_read() {
369        let dir = tempfile::tempdir().unwrap();
370        for (name, len) in [("small.con", 100), ("large.con", large_text().len())] {
371            let mut bytes = large_text()[..len].to_vec();
372            bytes[len - 40] = 0xff;
373            let path = dir.path().join(name);
374            std::fs::write(&path, &bytes).unwrap();
375            let err = read_file_text(&path).unwrap_err().to_string();
376            assert_eq!(Err(err), mapped_text(&path), "{name}");
377        }
378    }
379
380    #[test]
381    fn forward_text_reads_back_released_pages() {
382        let dir = tempfile::tempdir().unwrap();
383        let bytes = text_of_at_least(RELEASE_STEP + (200 << 10));
384        let path = dir.path().join("two-steps.con");
385        std::fs::write(&path, &bytes).unwrap();
386        let mut mapped = ForwardText::open(&path, true).unwrap();
387        assert!(matches!(mapped.contents, FileContents::Mapped(_)));
388        assert_eq!(mapped.as_str().as_bytes(), bytes.as_slice());
389        for pos in (0..=bytes.len()).step_by(1 << 20) {
390            mapped.release_before(pos).unwrap();
391        }
392        assert_eq!(mapped.released, RELEASE_STEP);
393        assert_eq!(mapped.as_str().as_bytes(), bytes.as_slice());
394        let owned = ForwardText::open(&path, false).unwrap();
395        assert!(matches!(owned.contents, FileContents::Owned(_)));
396        assert_eq!(owned.as_str(), mapped.as_str());
397    }
398
399    /// The guard is private anonymous memory right after the file map, which
400    /// `MADV_DONTNEED` would zero.
401    ///
402    /// The kernel puts a new map at the top of the highest free gap that fits
403    /// it. The test frees the space below the guard after it fills every gap
404    /// above, so the file map must end at the guard. A map made by another
405    /// test thread in between would take that space, so the test body runs
406    /// alone in a child process of this test binary.
407    #[cfg(target_os = "linux")]
408    #[test]
409    fn release_before_past_the_end_leaves_the_next_mapping_alone() {
410        const NAME: &str =
411            "compression::tests::release_before_past_the_end_leaves_the_next_mapping_alone";
412        const CHILD: &str = "READCON_RELEASE_TEST_CHILD";
413        if std::env::var_os(CHILD).is_none() {
414            let out = std::process::Command::new(std::env::current_exe().unwrap())
415                .args([NAME, "--exact", "--test-threads=1"])
416                .env(CHILD, "1")
417                .output()
418                .unwrap();
419            let stdout = String::from_utf8_lossy(&out.stdout);
420            assert!(
421                out.status.success() && stdout.contains("test result: ok. 1 passed;"),
422                "{stdout}{}",
423                String::from_utf8_lossy(&out.stderr)
424            );
425            return;
426        }
427        const GUARD: usize = 1 << 20;
428        let dir = tempfile::tempdir().unwrap();
429        let bytes = text_of_at_least(3 * MMAP_THRESHOLD as usize);
430        let path = dir.path().join("short.con");
431        std::fs::write(&path, &bytes).unwrap();
432        let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as usize;
433        let map_len = bytes.len().next_multiple_of(page);
434        let anonymous = |len: usize| {
435            let map = unsafe {
436                libc::mmap(
437                    std::ptr::null_mut(),
438                    len,
439                    libc::PROT_READ | libc::PROT_WRITE,
440                    libc::MAP_PRIVATE | libc::MAP_ANONYMOUS,
441                    -1,
442                    0,
443                )
444            };
445            assert_ne!(map, libc::MAP_FAILED);
446            map
447        };
448        let region = anonymous(map_len + GUARD);
449        let guard =
450            unsafe { std::slice::from_raw_parts_mut(region.cast::<u8>().add(map_len), GUARD) };
451        guard.fill(0xab);
452        // Each filler takes the highest gap that fits the file map. The first
453        // one below the region shows that no such gap is left above it.
454        let mut fillers = Vec::new();
455        loop {
456            let filler = anonymous(map_len);
457            if filler < region {
458                unsafe { libc::munmap(filler, map_len) };
459                break;
460            }
461            fillers.push(filler);
462        }
463        unsafe { libc::munmap(region, map_len) };
464        let mut text = ForwardText::open(&path, true).unwrap();
465        assert!(
466            std::ptr::eq(text.as_str().as_ptr(), region.cast::<u8>()),
467            "the file map does not end at the guard"
468        );
469        for pos in [bytes.len() + RELEASE_STEP, 3 * RELEASE_STEP, usize::MAX] {
470            text.release_before(pos).unwrap();
471        }
472        assert_eq!(text.released, bytes.len());
473        assert!(guard.iter().all(|&b| b == 0xab));
474        assert_eq!(text.as_str().as_bytes(), bytes.as_slice());
475        unsafe { libc::munmap(guard.as_mut_ptr().cast(), GUARD) };
476        for filler in fillers {
477            unsafe { libc::munmap(filler, map_len) };
478        }
479    }
480
481    #[test]
482    fn forward_text_reports_invalid_utf8_as_the_mapped_read() {
483        let dir = tempfile::tempdir().unwrap();
484        let mut bytes = text_of_at_least(RELEASE_STEP + (200 << 10));
485        let at = bytes.len() - 100;
486        bytes[at] = 0xc3;
487        let path = dir.path().join("bad.con");
488        std::fs::write(&path, &bytes).unwrap();
489        for map in [true, false] {
490            let err = ForwardText::open(&path, map).err().unwrap().to_string();
491            assert_eq!(Err(err), mapped_text(&path), "map {map}");
492        }
493    }
494}