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
//! Regression coverage for core PATH NORMALIZATION semantics.
//!
//! Two ONE-PLACE surfaces are exercised here:
//! 1. `keyhog_core::winpath::{has_windows_drive_prefix, is_windows_absolute}`
//! the BROAD (drive-prefix) vs STRICT (root-anchored) same-name-divergence
//! predicates. This file asserts the RELATIONSHIP between the two and a set
//! of boundary bools that are DISTINCT from `tests/unit/winpath.rs` (bare
//! `C:\`, bare `C:/`, `AB:/x`, `://x`).
//! 2. The lexical path canonicaliser reached through the public
//! `Allowlist::is_path_ignored`: `normalize_path` collapses `.`/`..`,
//! folds backslashes to `/`, strips the leading unix root, and drops
//! trailing/duplicate separators, applied to BOTH the ignore pattern and
//! the queried path. Every case pins the concrete normalized form by
//! observing an exact match/non-match bool.
//! 3. The SARIF URI relativiser (`sarif_relative_to` / `file_path_to_sarif_uri`
//! on the `CoreTestApi` testing facade), asserting concrete rendered
//! strings.
//!
//! HOST-INDEPENDENCE: none of these paths touch an accelerator or the
//! filesystem. `normalize_path` is a pure lexical transform over the given
//! string (`Path::components()` is not a syscall), and every SARIF case either
//! uses the pure `sarif_relative_to(path, explicit_root)` form or the
//! windows-absolute branch of `file_path_to_sarif_uri`, which resolves to a
//! `file:///` URI regardless of the process CWD (a Windows path can never be a
//! child of the unix scan root, so relativisation deterministically declines).
//! Component classification follows the compile target's rules (unix on the
//! test host), so a drive letter is a plain path segment here, asserted
//! explicitly rather than assumed.
//!
//! TEST TRUTH: every assertion is an exact bool / exact `Option<&str>` / exact
//! rendered `String`. No `is_empty()`/`is_some()`-only checks.
use keyhog_core::testing::{CoreTestApi, TestApi};
use keyhog_core::winpath::{has_windows_drive_prefix, is_windows_absolute};
use keyhog_core::Allowlist;
use std::path::Path;
/// Build an allowlist from a single `path:` ignore rule.
fn ignore(pattern: &str) -> Allowlist {
let content = format!("path:{pattern}\n");
CoreTestApi::allowlist_parse(&TestApi, &content)
}
// ---------------------------------------------------------------------------
// normalize_path via Allowlist::is_path_ignored (pure lexical canonicalisation)
// ---------------------------------------------------------------------------
#[test]
fn dotdot_parent_collapses_to_basename() {
// `./a/../b` -> `b`: the `.` is dropped, `a` is popped by the following
// `..`, leaving the single basename that the literal pattern matches.
let al = ignore("b");
assert_eq!(al.is_path_ignored("./a/../b"), true);
// Control: the already-collapsed form matches identically.
assert_eq!(al.is_path_ignored("b"), true);
}
#[test]
fn dotdot_collapse_negative_twin_differs_by_basename() {
// `./a/../c` -> `c`, which is NOT the ignored basename `b`. The collapse is
// lexical, so a one-character difference in the surviving segment misses.
let al = ignore("b");
assert_eq!(al.is_path_ignored("./a/../c"), false);
}
#[test]
fn trailing_slash_on_query_is_normalized_away() {
// `a/b/` normalizes to `a/b` (a trailing separator yields no extra
// component), so it matches the two-segment literal pattern; a genuine
// third segment does not.
let al = ignore("a/b");
assert_eq!(al.is_path_ignored("a/b/"), true);
assert_eq!(al.is_path_ignored("a/b"), true);
assert_eq!(al.is_path_ignored("a/b/c"), false);
}
#[test]
fn backslash_and_mixed_separators_fold_to_forward_slash() {
// `a\b\c` -> `a/b/c`: every backslash becomes `/` before component
// splitting, so a Windows-authored path matches a forward-slash pattern.
let al = ignore("a/b/c");
assert_eq!(al.is_path_ignored("a\\b\\c"), true);
assert_eq!(al.is_path_ignored("a\\b/c"), true);
// A prefix of the pattern (only two of three segments) must not match.
assert_eq!(al.is_path_ignored("a/b"), false);
}
#[test]
fn leading_unix_root_is_stripped() {
// A leading `/` clears accumulated segments (root reset), so `/etc/passwd`
// normalizes to the root-relative `etc/passwd` and matches the pattern.
let al = ignore("etc/passwd");
assert_eq!(al.is_path_ignored("/etc/passwd"), true);
assert_eq!(al.is_path_ignored("etc/passwd"), true);
assert_eq!(al.is_path_ignored("/etc/shadow"), false);
}
#[test]
fn windows_drive_letter_is_a_plain_segment_on_unix_host() {
// On the (unix) test target a leading `C:` is NOT a filesystem prefix; it is
// an ordinary `Normal` component. So `C:\Users\me` folds to the segment
// sequence `C:` / `Users` / `me` and matches the equivalent pattern.
let al = ignore("C:/Users/me");
assert_eq!(al.is_path_ignored("C:\\Users\\me"), true);
assert_eq!(al.is_path_ignored("C:/Users/me"), true);
assert_eq!(al.is_path_ignored("C:\\Users\\you"), false);
}
#[test]
fn dotdot_beyond_root_is_retained_not_dropped() {
// `a/../../b`: the first `..` pops `a`, the second `..` has nothing to pop
// and is preserved, giving `../b`. A single `..` (`a/../b` -> `b`) does not
// match the retained-`..` pattern.
let al = ignore("../b");
assert_eq!(al.is_path_ignored("a/../../b"), true);
assert_eq!(al.is_path_ignored("../b"), true);
assert_eq!(al.is_path_ignored("a/../b"), false);
}
#[test]
fn duplicate_separators_collapse() {
// `a//b` -> `a/b`: the empty component between the doubled slash is dropped.
let al = ignore("a/b");
assert_eq!(al.is_path_ignored("a//b"), true);
assert_eq!(al.is_path_ignored("a///b"), true);
}
#[test]
fn empty_query_path_never_matches_a_literal_pattern() {
// The empty string normalizes to zero segments; a single-segment literal
// pattern can never match a zero-segment path. Boundary/adversarial.
let al = ignore("a");
assert_eq!(al.is_path_ignored(""), false);
// `.` alone also normalizes to zero segments.
assert_eq!(al.is_path_ignored("."), false);
assert_eq!(al.is_path_ignored("./"), false);
}
// ---------------------------------------------------------------------------
// winpath predicates: exact bools + the same-name-divergence relationship
// ---------------------------------------------------------------------------
#[test]
fn is_windows_absolute_exact_boundary_bools() {
// STRICT sense: needs drive letter + colon + a separator at byte 2.
// Minimal root-anchored forms (bare `C:\` and `C:/`) are absolute.
assert_eq!(is_windows_absolute("C:\\"), true);
assert_eq!(is_windows_absolute("C:/"), true);
assert_eq!(is_windows_absolute("z:/x"), true);
// Two-letter "drive" (`AB:`) fails: byte 1 is `B`, not `:`.
assert_eq!(is_windows_absolute("AB:/x"), false);
// Non-alphabetic leading byte fails even with a following separator.
assert_eq!(is_windows_absolute("://x"), false);
// Colon present but next byte is not a separator -> drive-relative, not
// absolute.
assert_eq!(is_windows_absolute("C:\n"), false);
}
#[test]
fn drive_prefix_and_absolute_diverge_on_the_same_string() {
// The whole point of the ONE-PLACE split: a bare drive and a drive-relative
// path carry a drive PREFIX (broad reject sense) yet are NOT absolute
// (strict root-anchored sense).
assert_eq!(has_windows_drive_prefix("C:"), true);
assert_eq!(is_windows_absolute("C:"), false);
assert_eq!(has_windows_drive_prefix("C:evil"), true);
assert_eq!(is_windows_absolute("C:evil"), false);
// A fully-qualified path satisfies BOTH predicates.
assert_eq!(has_windows_drive_prefix("C:\\dir"), true);
assert_eq!(is_windows_absolute("C:\\dir"), true);
// A plain relative path satisfies NEITHER.
assert_eq!(has_windows_drive_prefix("relative/path"), false);
assert_eq!(is_windows_absolute("relative/path"), false);
}
// ---------------------------------------------------------------------------
// SARIF URI relativisation / rendering (concrete strings, CWD-independent)
// ---------------------------------------------------------------------------
#[test]
fn sarif_relative_to_repo_relative_and_outside_root() {
// Under the explicit root -> forward-slashed repo-relative path.
let inside = TestApi.sarif_relative_to("/repo/src/x.env", Path::new("/repo"));
assert_eq!(inside.as_deref(), Some("src/x.env"));
// Outside the root -> None (fail-closed; caller emits an absolute file://).
let outside = TestApi.sarif_relative_to("/other/x.env", Path::new("/repo"));
assert_eq!(outside.as_deref(), None);
}
#[test]
fn sarif_relative_to_folds_backslashes_and_handles_root_identity() {
// Backslashes in the input are folded before and after stripping the root.
let mixed = TestApi.sarif_relative_to("/repo\\src\\a.env", Path::new("/repo"));
assert_eq!(mixed.as_deref(), Some("src/a.env"));
// Path identical to the root strips to the empty relative path.
let root_itself = TestApi.sarif_relative_to("/repo", Path::new("/repo"));
assert_eq!(root_itself.as_deref(), Some(""));
}
#[test]
fn file_path_to_sarif_uri_windows_absolute_renders_file_scheme() {
// A Windows-absolute path can never live under the unix scan root, so
// relativisation deterministically declines and the strict-absolute branch
// renders a `file:///` URI with backslashes folded to `/`. CWD-independent.
let uri = TestApi.file_path_to_sarif_uri("C:\\proj\\secret.env");
assert_eq!(uri, "file:///C:/proj/secret.env");
}