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
//! Regression coverage for the scanner's HTML-entity decode-through paths
//! (`crates/scanner/src/decode/url.rs`: `html_named_entity_decode` /
//! `html_numeric_entity_decode`, registered as the `html-named-entity` and
//! `html-numeric-entity` decoders).
//!
//! These decoders have no direct `*_for_test` facade, so every case drives the
//! real decode pipeline via `testing::decode_chunk` (depth 2, which admits
//! chunk-level candidate extraction) and asserts the EXACT decoded bytes of the
//! emitted layer, or the EXACT count (0) when a malformed/unknown entity must
//! not decode. Each named/numeric replacement is pinned to its concrete
//! character, and the two decoders' mutually-exclusive filters
//! (`contains('&')` vs `contains("&#")`) are verified as negative twins.
//!
//! Distinct from `regression_url_qp_html_decoders.rs`: that file spot-checks a
//! handful of entities alongside url/qp/octal/mime; this file exhaustively pins
//! the named table (`&`/`<`/`>`/`"`/`'`/` `), the
//! numeric decimal/hex-lower/hex-upper forms, prefix preservation, malformed
//! pass-through, the semicolon requirement, surrogate/astral boundaries, and
//! the cross-decoder filter isolation.
#![cfg(feature = "decode")]
use keyhog_core::Chunk;
use keyhog_scanner::testing::decode_chunk;
/// Run the whole decode pipeline over `text` and return the `data` of every
/// emitted layer whose `source_type` names `decoder` (exactly one of
/// `"html-named-entity"` / `"html-numeric-entity"`: neither is a substring of
/// the other). Depth 2 admits the chunk-level candidate extraction.
fn layers_for(text: &str, decoder: &str) -> Vec<String> {
let chunk = Chunk {
data: text.into(),
metadata: Default::default(),
};
decode_chunk(&chunk, 2, false, None, None)
.into_iter()
.filter(|c| c.metadata.source_type.contains(decoder))
.map(|c| c.data.as_str().to_owned())
.collect()
}
/// True iff at least one emitted layer for `decoder` contains `needle`.
fn any_layer_contains(text: &str, decoder: &str, needle: &str) -> bool {
layers_for(text, decoder).iter().any(|d| d.contains(needle))
}
// ---------------------------------------------------------------------------
// Named entities `&name;`: every entry in the decode table, pinned exactly
// ---------------------------------------------------------------------------
#[test]
fn named_amp_decodes_to_ampersand() {
// `&` -> `&`; the surrounding `a`/`b` copy through unchanged.
assert!(
any_layer_contains("k = \"a&b\"", "html-named-entity", "a&b"),
"& must decode to & (a&b); got {:?}",
layers_for("k = \"a&b\"", "html-named-entity")
);
// Negative: the raw entity must not survive in any decoded layer.
assert!(
layers_for("k = \"a&b\"", "html-named-entity")
.iter()
.all(|d| !d.contains("&")),
"decoded layer must not still contain the raw & entity"
);
}
#[test]
fn named_lt_and_gt_decode_to_angle_brackets() {
// `<` -> `<`, `>` -> `>`.
assert!(
any_layer_contains("k = \"<tag>\"", "html-named-entity", "<tag>"),
"</> must decode to <tag>; got {:?}",
layers_for("k = \"<tag>\"", "html-named-entity")
);
}
#[test]
fn named_quot_decodes_to_double_quote() {
// `"` -> `"` (0x22).
assert!(
any_layer_contains("v = "x"", "html-named-entity", "\"x\""),
"" must decode to a literal double quote; got {:?}",
layers_for("v = "x"", "html-named-entity")
);
}
#[test]
fn named_apos_decodes_to_single_quote() {
// `'` -> `'` (0x27).
assert!(
any_layer_contains("k = \"a'b\"", "html-named-entity", "a'b"),
"' must decode to a single quote (a'b); got {:?}",
layers_for("k = \"a'b\"", "html-named-entity")
);
}
#[test]
fn named_nbsp_decodes_to_u00a0() {
// ` ` -> U+00A0 (non-breaking space), NOT an ASCII 0x20 space.
let layers = layers_for("k = \"x y\"", "html-named-entity");
assert!(
layers.iter().any(|d| d.contains("x\u{00A0}y")),
" must decode to U+00A0; got {layers:?}"
);
// Negative twin: it is not a plain ASCII space.
assert!(
layers.iter().all(|d| !d.contains("x y")),
" must not collapse to an ASCII space; got {layers:?}"
);
}
#[test]
fn named_prefix_before_first_entity_is_preserved() {
// lazy_decoded_prefix copies everything before the first successful decode:
// `PREFIX&TAIL` -> `PREFIX&TAIL`.
assert!(
any_layer_contains(
"k = \"PREFIX&TAIL\"",
"html-named-entity",
"PREFIX&TAIL"
),
"text before the first entity must be preserved verbatim; got {:?}",
layers_for("k = \"PREFIX&TAIL\"", "html-named-entity")
);
}
// ---------------------------------------------------------------------------
// Named entities, negative / adversarial
// ---------------------------------------------------------------------------
#[test]
fn named_unknown_entity_emits_no_named_layer() {
// `½` is not in the decode table; with nothing else decoded the
// decoder reports "nothing changed" and emits zero html-named-entity
// layers (exact count 0), never a spurious pass-through layer.
let layers = layers_for("k = \"½z\"", "html-named-entity");
assert_eq!(
layers.len(),
0,
"unknown named entity must yield zero html-named-entity layers; got {layers:?}"
);
}
#[test]
fn named_unknown_entity_after_known_passes_through_literally() {
// Once a real entity (`&` -> `&`) has started the decoded buffer, a
// following UNKNOWN entity is copied verbatim: `&½` ->
// `&½` (decoded amp, then literal `½`).
assert!(
any_layer_contains("k = \"&½\"", "html-named-entity", "&½"),
"unknown entity after a known one must pass through literally; got {:?}",
layers_for("k = \"&½\"", "html-named-entity")
);
}
#[test]
fn named_entity_without_semicolon_does_not_decode() {
// `&` (no terminating `;`, end of candidate) does not match the table,
// so no html-named-entity layer is emitted (exact count 0).
let layers = layers_for("k = \"&\"", "html-named-entity");
assert_eq!(
layers.len(),
0,
"a named entity missing its `;` must not decode; got {layers:?}"
);
// Negative twin: adding the `;` back DOES decode to `&`.
assert!(
any_layer_contains("k = \"&\"", "html-named-entity", "\"&\""),
"the same entity WITH `;` must decode to &; got {:?}",
layers_for("k = \"&\"", "html-named-entity")
);
}
// ---------------------------------------------------------------------------
// Numeric entities `&#NN;` / `&#xNN;` / `&#XNN;`
// ---------------------------------------------------------------------------
#[test]
fn numeric_decimal_entity_decodes_to_exact_char() {
// `A` = decimal 65 = 'A'.
let layers = layers_for("k = \"Aend\"", "html-numeric-entity");
assert!(
layers.iter().any(|d| d.contains("Aend")),
"A must decode to 'A' (Aend); got {layers:?}"
);
// The raw numeric entity must not survive.
assert!(
layers.iter().all(|d| !d.contains("A")),
"decoded layer must not still contain the raw A entity; got {layers:?}"
);
}
#[test]
fn numeric_hex_entity_lowercase_x_decodes() {
// `A` = hex 0x41 = 'A'.
assert!(
any_layer_contains("k = \"Aend\"", "html-numeric-entity", "Aend"),
"A must decode to 'A'; got {:?}",
layers_for("k = \"Aend\"", "html-numeric-entity")
);
}
#[test]
fn numeric_hex_entity_uppercase_x_decodes() {
// The decoder accepts an uppercase `X` prefix too: `A` = 'A'.
assert!(
any_layer_contains("k = \"Aend\"", "html-numeric-entity", "Aend"),
"A (uppercase X) must decode to 'A'; got {:?}",
layers_for("k = \"Aend\"", "html-numeric-entity")
);
}
#[test]
fn numeric_empty_entity_emits_no_numeric_layer() {
// `&#;` has no digits; nothing decodes (`changed` stays false) so zero
// html-numeric-entity layers are emitted (exact count 0).
let layers = layers_for("k = \"&#;z\"", "html-numeric-entity");
assert_eq!(
layers.len(),
0,
"digit-less numeric entity must yield zero numeric layers; got {layers:?}"
);
}
#[test]
fn numeric_valid_then_malformed_hex_keeps_valid_and_passes_malformed() {
// `A` decodes to 'A' (sets `changed`), then `&#xZZ;` is malformed (the
// `Z` is not a hex digit) and is copied through verbatim:
// `A&#xZZ;` -> `A&#xZZ;`.
assert!(
any_layer_contains("k = \"A&#xZZ;\"", "html-numeric-entity", "A&#xZZ;"),
"malformed hex after a valid entity must pass through literally; got {:?}",
layers_for("k = \"A&#xZZ;\"", "html-numeric-entity")
);
}
#[test]
fn numeric_astral_codepoint_decodes_to_multibyte_char() {
// Boundary: an astral-plane codepoint. `😀` = U+1F600 (😀).
let layers = layers_for("k = \"😀\"", "html-numeric-entity");
assert!(
layers.iter().any(|d| d.contains('\u{1F600}')),
"😀 must decode to U+1F600; got {layers:?}"
);
}
#[test]
fn numeric_surrogate_codepoint_is_rejected() {
// Adversarial boundary: `�` is a lone UTF-16 surrogate; it is not a
// valid Unicode scalar so `char::from_u32` fails and the decoder emits zero
// html-numeric-entity layers (exact count 0), never an invalid layer.
let layers = layers_for("k = \"�\"", "html-numeric-entity");
assert_eq!(
layers.len(),
0,
"a surrogate codepoint must not decode to a layer; got {layers:?}"
);
}
// ---------------------------------------------------------------------------
// Cross-decoder filter isolation (negative twins)
// ---------------------------------------------------------------------------
#[test]
fn numeric_only_input_emits_no_named_layer() {
// `A` contains `&` so the NAMED decoder runs its filter, but `#65;` is
// not a table entry, so the named decoder emits nothing: zero
// html-named-entity layers for a purely-numeric entity (exact count 0).
let layers = layers_for("k = \"Aend\"", "html-named-entity");
assert_eq!(
layers.len(),
0,
"a numeric entity must not produce an html-named-entity layer; got {layers:?}"
);
}
#[test]
fn named_only_input_emits_no_numeric_layer() {
// `&` lacks the `&#` prefix, so the numeric decoder's filter rejects it
// and zero html-numeric-entity layers are emitted (exact count 0).
let layers = layers_for("k = \"a&b\"", "html-numeric-entity");
assert_eq!(
layers.len(),
0,
"a named entity must not produce an html-numeric-entity layer; got {layers:?}"
);
}
// ---------------------------------------------------------------------------
// End-to-end: a secret wrapped in entities is recovered contiguously
// ---------------------------------------------------------------------------
#[test]
fn secret_behind_named_quot_entities_recovers_contiguously() {
// A `key="value"` secret whose quotes were HTML-escaped as `"` must
// recover to the exact contiguous `password="s3cr3tV4lue"` after decode so
// the downstream scanner sees the real assignment, not the escaped form.
assert!(
any_layer_contains(
"password="s3cr3tV4lue"",
"html-named-entity",
"password=\"s3cr3tV4lue\"",
),
"entity-wrapped secret must recover to password=\"s3cr3tV4lue\"; got {:?}",
layers_for("password="s3cr3tV4lue"", "html-named-entity")
);
}
#[test]
fn secret_behind_numeric_entities_recovers_contiguously() {
// Each character of a token encoded as decimal entities: `ABC`
// = 'A''B''C'. The decoded layer must contain the contiguous run "ABC".
assert!(
any_layer_contains("tok = \"ABC\"", "html-numeric-entity", "ABC"),
"numeric-entity-encoded token must recover to ABC; got {:?}",
layers_for("tok = \"ABC\"", "html-numeric-entity")
);
}