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
//! Regression coverage that pins the `MIN_STRING_LEN` (== 8) boundary of the
//! binary source's printable-string extractor (`crates/sources/src/strings.rs`,
//! reached through the strings-only `BinarySource`).
//!
//! `extract_printable_strings` / `MIN_STRING_LEN` are `pub(crate)`, so an
//! external integration test cannot call them directly. Instead we drive the
//! real operator path: write a byte blob to a file, scan it with a strings-only
//! `BinarySource` (via the `testing` facade, so Ghidra is never consulted and
//! the result is host-independent), and read the `binary:strings` chunk whose
//! body is the extracted runs joined by `\n`. No extracted run can contain a
//! `\n` (newline is neither graphic nor space/tab, so it always breaks a run),
//! therefore splitting the body on `\n` recovers the exact run list.
//!
//! The bytes written here are never a valid ELF/PE/Mach-O/archive magic, so
//! goblin's section pass returns `None` and the only emitted chunk is the
//! whole-file `binary:strings` chunk. When NOTHING reaches the threshold the
//! source must NOT report a clean file, it emits exactly one `SourceError`
//! row (Law 10), which the boundary-drop tests below assert on.
//!
//! Distinct from `regression_binary_strings_extract.rs`: that file surveys the
//! extractor broadly; this file drills the 6/7/8/9 length boundary from every
//! angle, alone-drop, alone-keep, leading/trailing EOF flush, whitespace and
//! symbol runs counting toward length, capacity-growth of a long run, the
//! UTF-16LE wide pass honouring the identical 7-drop/8-keep threshold, and a
//! planted secret surfacing verbatim.
#![cfg(feature = "binary")]
use keyhog_core::{Chunk, Source, SourceError};
use keyhog_sources::testing::{SourceTestApi, TestApi};
/// Scan `bytes` as a strings-only binary and return every row (chunks + errors).
fn source_rows(bytes: &[u8]) -> Vec<Result<Chunk, SourceError>> {
let dir = tempfile::tempdir().expect("create tempdir");
let path = dir.path().join("fixture.bin");
std::fs::write(&path, bytes).expect("write fixture bytes");
// `dir` stays alive until end of fn; `.chunks().collect()` reads the file now.
TestApi.binary_strings_only(path.clone()).chunks().collect()
}
/// The exact extracted-run list: the `binary:strings` chunk body split on `\n`.
fn extracted_runs(bytes: &[u8]) -> Vec<String> {
let rows = source_rows(bytes);
let mut lines = Vec::new();
for row in &rows {
if let Ok(chunk) = row {
if chunk.metadata.source_type.as_ref() == "binary:strings" {
let body = chunk.data.as_str().to_owned();
for line in body.split('\n') {
lines.push(line.to_string());
}
}
}
}
lines
}
/// Assert `bytes` yields NO `binary:strings` chunk and exactly one `SourceError`
/// naming the no-coverage gap (the below-threshold / nothing-extractable path).
fn assert_nothing_extractable(bytes: &[u8]) {
let rows = source_rows(bytes);
assert_eq!(
rows.len(),
1,
"a nothing-extractable binary must yield exactly one visible row, got {}",
rows.len()
);
let msg = match &rows[0] {
Ok(chunk) => panic!(
"expected a SourceError, got chunk source_type={}",
chunk.metadata.source_type
),
Err(err) => err.to_string(),
};
assert!(
msg.contains("yielded no scannable sections or printable strings")
&& msg.contains("no binary bytes were scanned"),
"error must name the no-strings coverage gap; got {msg}"
);
}
#[test]
fn run_of_six_alone_is_below_threshold_and_extracts_nothing() {
// 6 graphic chars ("SIXCHR") < MIN_STRING_LEN (8); with no NULs there is no
// wide candidate either, so the source reports a no-coverage error, never a
// clean file.
assert_nothing_extractable(b"\xFFSIXCHR\xFF");
}
#[test]
fn run_of_seven_alone_is_below_threshold_and_extracts_nothing() {
// Boundary minus one: exactly 7 graphic chars ("SEVENCH") is still dropped.
// Pins that the keep threshold is strictly greater than 7.
assert_nothing_extractable(b"\xFFSEVENCH\xFF");
}
#[test]
fn run_of_exactly_eight_alone_is_kept_verbatim() {
// Exactly MIN_STRING_LEN: "EIGHTLTR" is 8 graphic chars and is the sole run.
let runs = extracted_runs(b"\xFFEIGHTLTR\xFF");
assert_eq!(runs, vec!["EIGHTLTR".to_string()]);
}
#[test]
fn run_of_nine_is_kept_verbatim() {
// One above the boundary: "NINELTRSX" is 9 graphic chars, kept intact.
let runs = extracted_runs(b"\xFFNINELTRSX\xFF");
assert_eq!(runs, vec!["NINELTRSX".to_string()]);
}
#[test]
fn eight_char_run_before_six_char_run_keeps_only_the_eight() {
// Order-reversed boundary twin: the 8-char run comes first, the 6-char run
// second. Only "KEEPER88" (8) survives; "DROP66" (6) is dropped.
let runs = extracted_runs(b"\xFFKEEPER88\xFFDROP66\xFF");
assert_eq!(runs, vec!["KEEPER88".to_string()]);
assert!(
!runs.iter().any(|r| r.contains("DROP66")),
"the 6-char run must not survive; got {runs:?}"
);
}
#[test]
fn eight_spaces_count_toward_length_and_are_kept() {
// Adversarial: a run of 8 space bytes (0x20) is exactly MIN_STRING_LEN long
// because space is treated as printable and counts toward the length. The
// whole-space run is kept verbatim (and never wide-doubled: 0x20 is a
// non-zero UTF-16 high byte).
let mut bytes = vec![0xFF_u8];
bytes.extend(std::iter::repeat(b' ').take(8));
bytes.push(0xFF);
let runs = extracted_runs(&bytes);
assert_eq!(runs, vec![" ".repeat(8)]);
assert_eq!(runs.len(), 1, "the space run must not be wide-duplicated");
}
#[test]
fn seven_spaces_are_below_threshold_and_dropped() {
// Boundary twin of the above from the drop side: 7 space bytes are one short
// of MIN_STRING_LEN, so nothing is extractable.
let mut bytes = vec![0xFF_u8];
bytes.extend(std::iter::repeat(b' ').take(7));
bytes.push(0xFF);
assert_nothing_extractable(&bytes);
}
#[test]
fn symbol_and_digit_run_at_exactly_eight_is_kept() {
// Graphic set includes digits and punctuation. "a1_-b2.:" is 8 graphic
// chars (letters, digits, '_', '-', '.', ':') and is kept at the boundary.
let runs = extracted_runs(b"\xFFa1_-b2.:\xFF");
assert_eq!(runs, vec!["a1_-b2.:".to_string()]);
}
#[test]
fn long_run_of_128_chars_is_kept_without_capacity_truncation() {
// The extractor's `String::with_capacity(64)` is a hint, not a cap; a run far
// longer than 64 chars must survive intact. 128 'x' bytes, kept verbatim.
let long = "x".repeat(128);
let mut bytes = vec![0xFF_u8];
bytes.extend_from_slice(long.as_bytes());
bytes.push(0xFF);
let runs = extracted_runs(&bytes);
assert_eq!(runs, vec![long]);
}
#[test]
fn leading_run_at_offset_zero_of_exactly_eight_is_kept() {
// A run that begins at byte 0 (no leading delimiter) is still accumulated and
// flushed at its trailing NUL. "LEADING8" is 8 chars. (0x4C 0x45 start is not
// an ELF/PE/Mach magic, so the section pass returns None.)
let runs = extracted_runs(b"LEADING8\x00");
assert_eq!(runs, vec!["LEADING8".to_string()]);
}
#[test]
fn trailing_run_at_eof_of_exactly_eight_is_flushed() {
// A run that ends at EOF with no trailing delimiter is flushed by the
// post-loop tail check. "TRAILER8" (8 chars) sits at the end of the buffer.
let runs = extracted_runs(b"\x00TRAILER8");
assert_eq!(runs, vec!["TRAILER8".to_string()]);
}
#[test]
fn trailing_run_at_eof_of_seven_is_dropped() {
// Boundary twin of the tail flush: a 7-char run at EOF ("TRAIL77") is one
// short of the threshold, so the tail check drops it and nothing surfaces.
assert_nothing_extractable(b"\x00TRAIL77");
}
#[test]
fn utf16le_wide_run_of_exactly_eight_is_recovered() {
// The UTF-16LE pass honours the identical threshold: "WIDE1234" encoded as
// X 00 pairs is exactly 8 decoded chars and is recovered, even though the
// ASCII pass sees only length-1 runs interrupted by each 0x00.
let runs = extracted_runs(b"W\x00I\x00D\x00E\x001\x002\x003\x004\x00");
assert_eq!(runs, vec!["WIDE1234".to_string()]);
}
#[test]
fn utf16le_wide_run_of_seven_is_below_threshold_and_dropped() {
// Boundary minus one on the wide path: "WIDE123" is 7 decoded chars and is
// dropped; the ASCII pass only sees length-1 runs, so nothing is extractable.
assert_nothing_extractable(b"W\x00I\x00D\x00E\x001\x002\x003\x00");
}
#[test]
fn single_graphic_bytes_between_delimiters_never_reach_threshold() {
// Eight individual graphic chars each separated by a 0xFF delimiter: every
// ASCII run is length 1 (< 8) and the wide pass never matches (0xFF is a
// non-zero high byte), so length never accumulates across delimiters and
// nothing is extractable. Pins that length is per contiguous run.
assert_nothing_extractable(b"A\xFFB\xFFC\xFFD\xFFE\xFFF\xFFG\xFFH\xFF");
}
#[test]
fn planted_secret_in_binary_noise_surfaces_verbatim() {
// A 20-char AWS-shaped access key planted amid non-graphic noise is above the
// threshold and surfaces as the exact, sole run, the operator-visible point
// of the whole extractor.
let mut bytes = vec![0x00_u8, 0x01, 0x02];
bytes.extend_from_slice(b"AKIAIOSFODNN7EXAMPLE");
bytes.extend_from_slice(&[0x00, 0xFF]);
let runs = extracted_runs(&bytes);
assert_eq!(runs, vec!["AKIAIOSFODNN7EXAMPLE".to_string()]);
}