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
//! Standard input source: reads piped input as a single chunk for scanning.
use keyhog_core::{Chunk, ChunkMetadata, Source, SourceError};
use std::io::Read;
/// Reads all of stdin as a single chunk.
///
/// # Examples
///
/// ```rust
/// use keyhog_sources::StdinSource;
/// use keyhog_core::Source;
///
/// let source = StdinSource;
/// assert_eq!(source.name(), "stdin");
/// ```
pub struct StdinSource;
/// Stdin source with caller-resolved source limits.
pub struct ConfiguredStdinSource {
limits: crate::SourceLimits,
}
/// An already acquired stdin payload with the same decoding, limits, chunk
/// metadata, and source identity as [`StdinSource`].
///
/// This is useful for long-lived processes and calibration harnesses that own
/// the input bytes before source construction. It avoids mutating process
/// stdin or recreating its metadata contract in another crate.
pub struct BufferedStdinSource {
bytes: std::sync::Arc<[u8]>,
limits: crate::SourceLimits,
}
impl StdinSource {
pub fn with_limits(self, limits: crate::SourceLimits) -> ConfiguredStdinSource {
ConfiguredStdinSource { limits }
}
}
impl BufferedStdinSource {
pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
Self {
bytes: bytes.into().into(),
limits: crate::SourceLimits::default(),
}
}
#[must_use]
pub fn with_limits(mut self, limits: crate::SourceLimits) -> Self {
self.limits = limits;
self
}
}
impl Source for StdinSource {
fn name(&self) -> &str {
"stdin"
}
fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_> {
chunks_with_limit(crate::SourceLimits::default().stdin_bytes)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
impl Source for ConfiguredStdinSource {
fn name(&self) -> &str {
"stdin"
}
fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_> {
chunks_with_limit(self.limits.stdin_bytes)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
impl Source for BufferedStdinSource {
fn name(&self) -> &str {
"stdin"
}
fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_> {
crate::gate_scan(|| {
let mut reader = std::io::Cursor::new(self.bytes.as_ref());
one_stdin_chunk(read_to_string_limited(&mut reader, self.limits.stdin_bytes))
})
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
fn chunks_with_limit(max_bytes: usize) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>>> {
crate::gate_scan(|| one_stdin_chunk(read_stdin_limited(max_bytes)))
}
fn one_stdin_chunk(
result: std::io::Result<String>,
) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>>> {
Box::new(std::iter::once(match result {
Ok(data) => Ok(Chunk {
data: data.into(),
metadata: ChunkMetadata {
base_offset: 0,
base_line: 0,
source_type: "stdin".into(),
path: None,
commit: None,
author: None,
date: None,
mtime_ns: None,
size_bytes: None,
decoded_span: None,
},
}),
Err(error) => Err(SourceError::Io(error)),
}))
}
fn read_stdin_limited(max_bytes: usize) -> std::io::Result<String> {
read_to_string_limited(&mut std::io::stdin().lock(), max_bytes)
}
pub(crate) fn read_to_string_limited(
reader: &mut impl Read,
max_bytes: usize,
) -> std::io::Result<String> {
let cap = u64::try_from(max_bytes).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"stdin cap is too large for this platform",
)
})?;
// Read at most `max_bytes + 1` so oversized stdin is rejected before we
// hand a giant buffer to the scanner.
let read = crate::capped_read::read_to_cap(reader, cap, None)?;
if read.truncated {
let _event = crate::record_skip_event(crate::SourceSkipEvent::OverMaxSize);
return Err(std::io::Error::other(format!(
"stdin exceeds {} byte limit",
max_bytes
)));
}
// Lossy UTF-8 decode, matching the filesystem source's windowed/mmap reads
// (`String::from_utf8_lossy`): binary or mixed-encoding stdin is scanned for
// the text it does contain rather than rejected. Rejecting it made
// `cat binaryfile | keyhog scan --stdin` a source failure (exit 2 under the
// KH-GAP-096 fail-closed) while `keyhog scan binaryfile` happily lossy-scans
// the same bytes, an inconsistency, and real secrets do live in otherwise
// non-UTF-8 inputs (embedded configs, archive members, latin-1 logs). The
// size cap above already bounds memory.
//
// `from_utf8` (consuming the owned `Vec`) reuses the buffer's allocation on
// the common valid-UTF-8 path, zero copy, and only the rare invalid input
// pays the lossy re-encode; `from_utf8_lossy(&bytes).into_owned()` copied the
// whole stdin buffer even when it was already valid UTF-8.
match String::from_utf8(read.bytes) {
Ok(text) => Ok(text),
Err(err) => Ok(String::from_utf8_lossy(&err.into_bytes()).into_owned()),
}
}