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
//! Regression coverage for printable-string extraction in the binary source
//! (`crates/sources/src/strings.rs` + `binary/literals.rs`), reached through the
//! public `testing` facade's strings-only `BinarySource`.
//!
//! `extract_printable_strings` is `pub(crate)`, so an external integration test
//! cannot call it directly. Instead we drive the real operator path: write a
//! byte blob to a file, scan it with a strings-only `BinarySource`, and read the
//! `binary:strings` chunk whose body is the extracted runs joined by `\n`
//! (`join_sensitive_strings(&strings, "\n")`). No extracted run ever contains a
//! `\n` (newline is neither graphic nor space/tab, so it always breaks a run),
//! therefore splitting that body on `\n` recovers the exact run list.
//!
//! `MIN_STRING_LEN` is hard-wired to 8 on this path, so every threshold
//! assertion below pins the 7-drop / 8-keep boundary at that value.
//!
//! Covered: exact run above threshold, 7-vs-8 boundary, short run dropped among
//! valid ones, embedded-NUL run splitting (single + multiple), space/tab kept
//! inside a run, newline / control / high-byte delimiters, UTF-16LE wide-string
//! recovery + its below-threshold drop, the pure-ASCII no-wide-duplication twin,
//! ASCII-before-wide ordering, the all-below-threshold source-error path, and
//! the emitted chunk's `source_type`.
#![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
}
#[test]
fn single_run_of_exactly_min_len_is_extracted() {
// "password" is exactly 8 graphic chars (== MIN_STRING_LEN); 0xFF sentinels
// are non-graphic delimiters that never become a zero UTF-16 high byte.
let runs = extracted_runs(b"\xFFpassword\xFF");
assert_eq!(runs, vec!["password".to_string()]);
}
#[test]
fn seven_char_run_dropped_eight_char_run_kept() {
// Pins the threshold: 7 graphic chars are below MIN_STRING_LEN and dropped,
// 8 are kept. "AAAAAAA" = 7, "BBBBBBBB" = 8.
let runs = extracted_runs(b"\xFFAAAAAAA\xFFBBBBBBBB\xFF");
assert_eq!(runs, vec!["BBBBBBBB".to_string()]);
assert!(
!runs.iter().any(|r| r.contains("AAAAAAA")),
"the 7-char run must not survive; got {runs:?}"
);
}
#[test]
fn short_run_dropped_among_valid_runs() {
// "validcredential" = 15 kept, "short" = 5 dropped.
let runs = extracted_runs(b"\xFFvalidcredential\xFFshort\xFF");
assert_eq!(runs, vec!["validcredential".to_string()]);
assert!(
!runs.iter().any(|r| r.contains("short")),
"a 5-char run is below threshold and must be dropped; got {runs:?}"
);
}
#[test]
fn embedded_nul_splits_run_into_exactly_two() {
// A single NUL between two 8-char runs breaks the run at exactly that byte.
// The lone 'A' the UTF-16 pass sees at the A|NUL boundary is length 1 (< 8)
// and is dropped, so no spurious wide string appears.
let runs = extracted_runs(b"AAAAAAAA\x00BBBBBBBB");
assert_eq!(runs, vec!["AAAAAAAA".to_string(), "BBBBBBBB".to_string()]);
}
#[test]
fn multiple_nuls_split_into_exactly_three_runs() {
// Runs: 14 / 13 / 12 chars, separated by 3 and 2 NULs. Each graphic char
// immediately before a NUL yields only a length-1 UTF-16 candidate (dropped),
// so the ASCII pass alone defines the result.
let runs = extracted_runs(b"COMMITHASH1234\x00\x00\x00DEPLOYTOKEN99\x00\x00FINALSEGMENT");
assert_eq!(
runs,
vec![
"COMMITHASH1234".to_string(),
"DEPLOYTOKEN99".to_string(),
"FINALSEGMENT".to_string(),
]
);
}
#[test]
fn space_and_tab_are_kept_within_a_run() {
// Space (0x20) and tab (0x09) are treated as printable and stay inside a run;
// "key = value123" (with two spaces) is one 14-char run.
let spaces = extracted_runs(b"\xFFkey = value123\xFF");
assert_eq!(spaces, vec!["key = value123".to_string()]);
// Tab likewise stays inside: "token\tvalue" is 11 chars, one run.
let tabbed = extracted_runs(b"\xFFtoken\tvalue\xFF");
assert_eq!(tabbed, vec!["token\tvalue".to_string()]);
}
#[test]
fn newline_breaks_a_run_unlike_space_and_tab() {
// Newline (0x0A) is neither graphic nor space/tab, so it is a delimiter:
// "firsthalf" (9) and "secondhalf" (10) become two separate runs.
let runs = extracted_runs(b"\xFFfirsthalf\nsecondhalf\xFF");
assert_eq!(
runs,
vec!["firsthalf".to_string(), "secondhalf".to_string()]
);
}
#[test]
fn control_and_high_bytes_break_runs() {
// 0x07 (BEL), 0x1F (unit sep), and high bytes 0x80/0x90/0xFF are all
// non-printable delimiters; only the two 10-char graphic runs survive.
let runs = extracted_runs(b"\x07\x80segmentone\x1F\x90segmenttwo\xFF");
assert_eq!(
runs,
vec!["segmentone".to_string(), "segmenttwo".to_string()]
);
}
#[test]
fn utf16le_wide_string_is_recovered() {
// Each ASCII byte followed by 0x00 (UTF-16LE): the ASCII pass sees only
// length-1 runs (all dropped), but the wide pass reconstructs the 9-char
// "SECRETKEY". This is the encoding the ASCII pass alone would miss.
let runs = extracted_runs(b"S\x00E\x00C\x00R\x00E\x00T\x00K\x00E\x00Y\x00");
assert_eq!(runs, vec!["SECRETKEY".to_string()]);
}
#[test]
fn pure_ascii_text_is_not_wide_duplicated() {
// Negative twin for the wide pass: on pure ASCII every high byte is non-zero,
// so the UTF-16LE pass never matches and adds no spurious duplicate. Exactly
// one 20-char run, not two.
let runs = extracted_runs(b"\xFFhelloworldfromkeyhog\xFF");
assert_eq!(runs, vec!["helloworldfromkeyhog".to_string()]);
assert_eq!(runs.len(), 1, "pure ASCII must not be wide-doubled");
}
#[test]
fn wide_run_below_threshold_dropped_ascii_run_kept() {
// Wide "ABCDEFG" is 7 decoded chars (< 8) and is dropped; the trailing ASCII
// "KEEPME12" is exactly 8 and survives. Proves the wide pass honors the same
// threshold and re-aligns past the 0xFF sentinel.
let runs = extracted_runs(b"A\x00B\x00C\x00D\x00E\x00F\x00G\x00\xFFKEEPME12\xFF");
assert_eq!(runs, vec!["KEEPME12".to_string()]);
assert!(
!runs.iter().any(|r| r.contains("ABCDEFG")),
"a 7-char wide run must be dropped; got {runs:?}"
);
}
#[test]
fn ascii_runs_precede_wide_runs_in_output() {
// extract_printable_strings appends the ASCII pass first, then the UTF-16LE
// pass, so ordering is deterministic: ASCII "PLAINTEXTRUN" (12) then wide
// "WIDERUNX" (8).
let runs = extracted_runs(b"\xFFPLAINTEXTRUN\xFFW\x00I\x00D\x00E\x00R\x00U\x00N\x00X\x00");
assert_eq!(
runs,
vec!["PLAINTEXTRUN".to_string(), "WIDERUNX".to_string()]
);
assert_eq!(runs[0], "PLAINTEXTRUN");
assert_eq!(runs[1], "WIDERUNX");
}
#[test]
fn all_runs_below_threshold_yields_source_error_not_empty_chunk() {
// "short" (5) and "tiny" (4) are both below threshold; with no NULs there is
// no wide candidate either, so nothing is extractable. The source must NOT
// report a clean file (it emits exactly one SourceError row (Law 10)).
let rows = source_rows(b"\xFFshort\xFFtiny\xFF");
assert_eq!(
rows.len(),
1,
"an all-sub-threshold binary must yield exactly one visible row"
);
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 extracted_runs_are_emitted_in_a_single_binary_strings_chunk() {
// The extraction result is delivered as exactly one chunk whose source_type
// is "binary:strings" (not e.g. a section or ghidra chunk on this path).
let rows = source_rows(b"\xFFalphaonewinnerstring\xFF");
let strings_chunks: Vec<&Chunk> = rows
.iter()
.filter_map(|r| r.as_ref().ok())
.filter(|c| c.metadata.source_type.as_ref() == "binary:strings")
.collect();
assert_eq!(
strings_chunks.len(),
1,
"exactly one binary:strings chunk expected"
);
assert_eq!(
strings_chunks[0].metadata.source_type.as_ref(),
"binary:strings"
);
assert_eq!(
strings_chunks[0].data.to_string(),
"alphaonewinnerstring",
"the sole run must be the chunk body verbatim"
);
}