keyhog-sources 0.5.44

keyhog-sources: pluggable input backends for KeyHog (git, S3, GCS, Azure Blob, Docker, Web)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
//! Safe `open`, buffered read, and whole-file mmap. All paths route
//! through [`open_file_safe`] which refuses to follow symlinks (a
//! scan tricked into reading `~/.aws/credentials` is a real attack
//! we already saw in the wild).

use memmap2::MmapOptions;
use std::fs::File;
use std::io::Read;
use std::path::Path;

use super::decode::decode_text_file_owned_or_bytes;
use super::MMAP_TOCTOU_SANITY_CAP_BYTES;

pub(in crate::filesystem) enum BufferedFileRead {
    Text(String),
    Bytes(Vec<u8>),
    Mmap(memmap2::Mmap),
}

const MAX_EXACT_SIZED_READ_PREALLOC_BYTES: u64 = 16 * 1024 * 1024;

/// Hard ceiling on a single buffered (non-mmap) whole-file read. Set to the
/// same 2 GiB sanity cap the mmap path enforces post-open: `--max-file-size`
/// is validated against a pre-read stat, so a file grown after that stat (a
/// walker-stat-then-grow TOCTOU) must not be able to OOM the buffered path
/// either. The mmap twin re-stats and refuses; the buffered path bounds the
/// read with `.take(MAX_BUFFERED_READ_BYTES)`. (KH-GAP-013)
pub(super) const MAX_BUFFERED_READ_BYTES: u64 = MMAP_TOCTOU_SANITY_CAP_BYTES;

pub(in crate::filesystem) fn read_file_buffered(
    path: &Path,
    size_hint: u64,
) -> Option<BufferedFileRead> {
    // The buffered read already owns its `Vec<u8>`. Hand it to the owning
    // decoder so the valid-UTF-8 fast path can *move* the buffer straight
    // into the returned `String` (`String::from_utf8` reuses the same
    // allocation) instead of paying a full-file `s.to_owned()` heap copy.
    // At internet scale that copy is a whole extra pass over every byte
    // scanned on the hottest loop; the mmap path can't avoid it (its
    // backing store is borrowed), but the buffered path can and must.
    //
    // `size_hint` is the walker's already-known `entry.size`: `read_file_safe`
    // uses it to read the whole file in a single sized `read(2)` (no empty-Vec
    // capacity-doubling and no trailing EOF probe), instead of the many small
    // reads `read_to_end` does on a tiny file. See PERF-io_path-2.
    let bytes = match read_file_safe(path, size_hint) {
        Ok(b) => b,
        Err(error) => {
            tracing::warn!(
                path = %path.display(),
                %error,
                "cannot read file; skipping"
            );
            let skip = if error.kind() == std::io::ErrorKind::InvalidData {
                crate::SourceSkipEvent::OverMaxSize
            } else {
                crate::SourceSkipEvent::Unreadable
            };
            let _event = crate::record_skip_event(skip);
            return None;
        }
    };
    match decode_text_file_owned_or_bytes(bytes) {
        Ok(text) => Some(BufferedFileRead::Text(text)),
        Err(bytes) => Some(BufferedFileRead::Bytes(bytes)),
    }
}

/// Open `path` in a symlink-resistant, special-file-resistant way.
///
/// POSIX gets `O_NOFOLLOW` (never traverse a final symlink component) AND
/// `O_NONBLOCK`. The latter is load-bearing for a scanner that walks untrusted
/// trees: a plain `open(O_RDONLY)` of a FIFO with no writer BLOCKS FOREVER, so a
/// single named pipe anywhere in the scan set would hang the whole scan. With
/// `O_NONBLOCK` the open returns immediately for a FIFO, and the regular-file
/// check below then refuses it. `O_NONBLOCK` is inert on regular-file reads
/// (POSIX never returns `EAGAIN` for a regular file), so it does not change the
/// hot read path.
///
/// After the open succeeds we fstat the OPENED descriptor (not the path) and
/// refuse anything that is not a regular file. FIFO, socket, block/char device.
/// fstat'ing the fd we just opened, rather than re-stat'ing the path, closes the
/// TOCTOU window where the walker stats a regular file and an attacker swaps it
/// for a FIFO before this open (`O_NOFOLLOW` does not help, a FIFO is not a
/// symlink). A content scanner must never read from a special file; failing
/// closed here is surfaced loudly by the caller as a skip error, never silently.
///
/// Windows has no `O_NOFOLLOW`/`O_NONBLOCK` on `OpenOptions`, so it classifies
/// the path with `symlink_metadata` before open (small TOCTOU window, acceptable
/// for a defensive scanner) and the post-open regular-file check below still
/// applies. The shipped Windows contract is explicit refusal of symlink paths,
/// refusal of non-regular files, and fail-closed refusal when the file type
/// cannot be classified before the standard-library open.
pub(crate) fn open_file_safe(path: &Path) -> std::io::Result<File> {
    let mut options = std::fs::OpenOptions::new();
    options.read(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        // O_NONBLOCK so a FIFO/device open returns immediately instead of
        // blocking on a missing writer; O_NOFOLLOW so the final path component
        // is never a followed symlink.
        options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
    }
    // Windows has no equivalent of O_NOFOLLOW on `OpenOptions`. Without an
    // explicit symlink check, a scan could be tricked into following a
    // junction/symlink out of the scan root and reading a sensitive file
    // (e.g. `C:\Users\victim\.aws\credentials`). There is a small TOCTOU
    // window between `symlink_metadata` and `open` - for our defensive-
    // secret-scanning threat model that's an acceptable trade-off; the
    // attacker would need to win a race they don't even see initiated.
    // Keep this contract local and explicit: refuse a symlink path before
    // opening it through the cross-platform standard-library path.
    #[cfg(windows)]
    {
        let meta = std::fs::symlink_metadata(path)?;
        if meta.file_type().is_symlink() {
            return Err(std::io::Error::new(
                std::io::ErrorKind::PermissionDenied,
                "refusing to follow symlink (Windows safety guard)",
            ));
        }
    }
    let file = options.open(path)?;
    // Fail closed on any non-regular file. fstat the OPENED fd (the same object
    // the O_NONBLOCK open returned) so a FIFO/socket/device cannot reach the read
    // path: a FIFO read would hang, a device (`/dev/zero`) would stream until the
    // read cap, and neither is a scan target. Checking the fd, not the path
    // also closes the regular-file→FIFO TOCTOU swap. `is_file()` is true ONLY for
    // a regular file on every platform, so this one check covers all special
    // types (and a directory, which never reaches a content read anyway).
    if !file.metadata()?.is_file() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "refusing to read a non-regular file (FIFO, socket, or device)",
        ));
    }
    #[cfg(unix)]
    {
        use std::os::unix::io::AsRawFd;
        let fd = file.as_raw_fd();
        // SAFETY: advisory shared lock on a read-only descriptor. The lock is
        // held by the returned File until the read path drops it, preventing
        // locked/torn-write inputs from being reopened through a different
        // unlocked fallback path.
        if unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) } != 0 {
            return Err(std::io::Error::new(
                std::io::ErrorKind::PermissionDenied,
                "file is locked by another process",
            ));
        }
    }
    Ok(file)
}

pub(in crate::filesystem) fn read_file_prefix_safe(
    path: &Path,
    buf: &mut [u8],
) -> std::io::Result<usize> {
    let mut file = open_file_safe(path)?;
    let mut filled = 0;
    while filled < buf.len() {
        match file.read(&mut buf[filled..]) {
            Ok(0) => break,
            Ok(n) => filled += n,
            Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
            Err(e) => return Err(e),
        }
    }
    Ok(filled)
}

pub(in crate::filesystem) fn read_file_safe(
    path: &Path,
    size_hint: u64,
) -> std::io::Result<Vec<u8>> {
    // The previous implementation built an `IoUring::new(1)` per file, which
    // amortizes badly: ring setup + teardown is dominated by the syscalls
    // around the actual read for any file under ~1 GB. Plain buffered read
    // (and the `mmap` path used by `read_file_mmap`) outperformed it on the
    // standard corpus; see the internal design notes sources finding.
    // io_uring belongs in a shared batched owner with benchmark proof, not as
    // per-file ring setup in this hot-path read.
    let file = open_file_safe(path)?;
    // Hint to the kernel: this fd will be read sequentially start-to-end.
    // posix_fadvise(POSIX_FADV_SEQUENTIAL) doubles the readahead window
    // and disables prefetching past the end. Free perf on Linux; no-op
    // elsewhere. Linux kernel only - macOS lacks posix_fadvise.
    #[cfg(target_os = "linux")]
    {
        use std::os::unix::io::AsRawFd;
        let fd = file.as_raw_fd();
        // SAFETY: posix_fadvise is a syscall with documented behavior;
        // failure (EINVAL on tmpfs/proc, ESPIPE on pipes) is non-fatal -
        // we ignore it and proceed with the read.
        unsafe { libc::posix_fadvise(fd, 0, 0, libc::POSIX_FADV_SEQUENTIAL) };
    }
    // Bound any buffered read at MAX_BUFFERED_READ_BYTES so a TOCTOU-grown file
    // can't OOM us (the mmap twin re-stats and refuses; this is the buffered
    // equivalent). Legitimate text files sit far under the 2 GiB ceiling, so
    // this never truncates real input. (KH-GAP-013)
    let cap = size_hint.min(MAX_BUFFERED_READ_BYTES);
    if cap == 0 {
        // The caller did not know the size (size_hint == 0): fall back to the
        // grow-from-empty read, still bounded by the cap.
        let read = crate::capped_read::read_to_cap(file, MAX_BUFFERED_READ_BYTES, None)?;
        if read.truncated {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                format!(
                    "filesystem buffered read exceeded {} byte cap",
                    MAX_BUFFERED_READ_BYTES
                ),
            ));
        }
        return Ok(read.bytes);
    }

    if cap <= MAX_EXACT_SIZED_READ_PREALLOC_BYTES {
        return read_exact_stat_sized_with_growth_probe(file, cap);
    }

    let read = crate::capped_read::read_to_cap(file, cap, Some(cap))?;
    if read.truncated {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            buffered_read_exceeded_cap_message(size_hint, cap),
        ));
    }
    Ok(read.bytes)
}

fn read_exact_stat_sized_with_growth_probe(mut file: File, cap: u64) -> std::io::Result<Vec<u8>> {
    let cap_usize = usize::try_from(cap).map_err(|error| {
        std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            format!("filesystem buffered read cap is not addressable on this platform: {error}"),
        )
    })?;
    let mut bytes = vec![0u8; cap_usize];
    let mut filled = 0;
    while filled < cap_usize {
        match file.read(&mut bytes[filled..]) {
            Ok(0) => break,
            Ok(n) => filled += n,
            Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
            Err(e) => return Err(e),
        }
    }
    bytes.truncate(filled);
    if filled == cap_usize {
        let mut sentinel = [0u8; 1];
        loop {
            match file.read(&mut sentinel) {
                Ok(0) => break,
                Ok(_) => {
                    return Err(std::io::Error::new(
                        std::io::ErrorKind::InvalidData,
                        buffered_read_exceeded_cap_message(cap, cap),
                    ));
                }
                Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
                Err(e) => return Err(e),
            }
        }
    }
    Ok(bytes)
}

fn buffered_read_exceeded_cap_message(size_hint: u64, cap: u64) -> String {
    if size_hint <= MAX_BUFFERED_READ_BYTES {
        format!("filesystem buffered read exceeded stat-time {size_hint} byte cap")
    } else {
        format!(
            "filesystem buffered read exceeded {} byte sanity cap after stat-time size {size_hint}",
            cap
        )
    }
}

pub(in crate::filesystem) fn read_file_mmap(path: &Path) -> Option<BufferedFileRead> {
    let mut file = match open_file_safe(path) {
        Ok(f) => f,
        Err(error) => {
            tracing::warn!(
                path = %path.display(),
                %error,
                "cannot open file for mmap; skipping"
            );
            let _event = crate::record_skip_event(crate::SourceSkipEvent::Unreadable);
            return None;
        }
    };

    // Post-open re-stat: defeat the walker-stat-then-write race where
    // an attacker grows the file to multi-GiB between the walker's
    // size check and our mmap. The walker's max_file_size is the
    // user-configurable budget; this constant is a HARD ceiling on
    // any mmap-based read regardless of user config.
    let meta = match file.metadata() {
        Ok(meta) => meta,
        Err(error) => {
            tracing::warn!(
                path = %path.display(),
                %error,
                "cannot stat opened file for mmap sanity cap; skipping"
            );
            let _event = crate::record_skip_event(crate::SourceSkipEvent::Unreadable);
            return None;
        }
    };
    let live_size_hint = Some(meta.len());
    if meta.len() > MMAP_TOCTOU_SANITY_CAP_BYTES {
        tracing::warn!(
            path = %path.display(),
            live_size = meta.len(),
            cap = MMAP_TOCTOU_SANITY_CAP_BYTES,
            "refusing to mmap file: live size exceeds sanity cap (likely TOCTOU growth)"
        );
        let _event = crate::record_skip_event(crate::SourceSkipEvent::OverMaxSize);
        return None;
    }
    // NB: no re-flock here. `open_file_safe` (which opened `file`) already holds
    // the advisory `LOCK_SH` on this fd, and a shared lock we already hold blocks
    // any new exclusive lock, so a re-request could only re-confirm the lock we
    // own (a redundant syscall whose "locked by another process" failure branch
    // was dead). The lock stays held for `file`'s lifetime, which spans the mmap
    // below. ONE owner of the flock guard: `open_file_safe`. Contract pinned by
    // `externally_exclusive_locked_file_is_refused_by_open_and_mmap`.

    // SAFETY: the mapping is read-only, the `File` lives through the mapping
    // call, and we decode the bytes immediately without storing the mmap past
    // this function. Do not pass the earlier stat length into mmap: if the file
    // shrank between stat and map, a stale explicit length can create a range
    // past live EOF and SIGBUS when read. Map the kernel's current file length,
    // then enforce the hard cap before touching bytes.
    let mmap = match unsafe { MmapOptions::new().map(&file) } {
        Ok(m) => m,
        Err(error) => {
            tracing::warn!(
                path = %path.display(),
                %error,
                "cannot mmap file; falling back to buffered read"
            );
            // Same OOM guard as the locked-file fallback above: cap the buffered
            // read at the TOCTOU sanity ceiling so an mmap failure does not become
            // an unbounded `read_to_end` of a TOCTOU-grown file.
            // (KH-GAP-OOM-mmap-fallback)
            match crate::capped_read::read_to_cap(
                &mut file,
                MMAP_TOCTOU_SANITY_CAP_BYTES,
                live_size_hint,
            ) {
                Ok(read) => {
                    if read.truncated {
                        tracing::warn!(
                            path = %path.display(),
                            cap = MMAP_TOCTOU_SANITY_CAP_BYTES,
                            "file grew beyond mmap fallback sanity cap while reading"
                        );
                        let _event = crate::record_skip_event(crate::SourceSkipEvent::OverMaxSize);
                        return None;
                    }
                    return Some(match decode_text_file_owned_or_bytes(read.bytes) {
                        Ok(text) => BufferedFileRead::Text(text),
                        Err(bytes) => BufferedFileRead::Bytes(bytes),
                    });
                }
                Err(error) => {
                    tracing::warn!(
                        path = %path.display(),
                        %error,
                        "cannot read file after mmap failure; skipping"
                    );
                    let _event = crate::record_skip_event(crate::SourceSkipEvent::Unreadable);
                    return None;
                }
            }
        }
    };
    let mapped_len = match u64::try_from(mmap.len()) {
        Ok(len) => len,
        Err(error) => {
            tracing::warn!(
                path = %path.display(),
                mapped_len = mmap.len(),
                %error,
                "cannot represent mapped file length for mmap sanity cap; skipping"
            );
            let _event = crate::record_skip_event(crate::SourceSkipEvent::OverMaxSize);
            return None;
        }
    };
    if mapped_len > MMAP_TOCTOU_SANITY_CAP_BYTES {
        tracing::warn!(
            path = %path.display(),
            live_size = mapped_len,
            cap = MMAP_TOCTOU_SANITY_CAP_BYTES,
            "refusing to mmap file: mapped length exceeds sanity cap (likely TOCTOU growth)"
        );
        let _event = crate::record_skip_event(crate::SourceSkipEvent::OverMaxSize);
        return None;
    }

    // Tell the kernel we will read this mmap sequentially front-to-back,
    // not randomly. madvise(SEQUENTIAL) disables LRU protection on the
    // pages so they can be evicted faster (we won't re-read them) and
    // bumps readahead. Free perf on Linux/macOS, no-op elsewhere.
    #[cfg(unix)]
    {
        // SAFETY: madvise on a valid memory range returned by mmap; failure
        // is non-fatal - we ignore the return code.
        unsafe {
            libc::madvise(
                mmap.as_ptr() as *mut libc::c_void,
                mmap.len(),
                libc::MADV_SEQUENTIAL,
            );
        }
    }

    let result = match super::decode::decode_text_file(&mmap) {
        Some(text) => BufferedFileRead::Text(text),
        None => BufferedFileRead::Mmap(mmap),
    };

    #[cfg(unix)]
    {
        use std::os::unix::io::AsRawFd;
        let fd = file.as_raw_fd();
        // SAFETY: Simple advisory unlock FFI call.
        unsafe { libc::flock(fd, libc::LOCK_UN) };
    }

    Some(result)
}