1use std::io::{self, Read};
6use std::path::Path;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Compression {
16 None,
17 Gzip,
18 Zstd,
20}
21
22pub 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
42pub 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
54const MMAP_THRESHOLD: u64 = 64 * 1024;
56
57pub fn read_file_contents(path: &Path) -> Result<FileContents, Box<dyn std::error::Error>> {
68 read_file_contents_detected(path).map(|(contents, _)| contents)
69}
70
71pub fn read_file_contents_detected(
73 path: &Path,
74) -> Result<(FileContents, Compression), Box<dyn std::error::Error>> {
75 read_contents(path, true)
76}
77
78pub 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 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 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 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
156pub 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
178pub 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 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
209const RELEASE_STEP: usize = 32 << 20;
211
212pub struct ForwardText {
223 contents: FileContents,
224 released: usize,
225}
226
227impl ForwardText {
228 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 FileContents::Mapped(m) => unsafe { std::str::from_utf8_unchecked(m) },
260 }
261 }
262
263 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
278fn release(map: &memmap2::Mmap, start: usize, end: usize) -> io::Result<()> {
281 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 #[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
300pub 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#[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 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 #[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 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}