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
319
320
321
322
323
324
325
//! Regression: GitLab group/project *classification*, the pure, host-independent
//! path that parses a GitLab namespace, builds the `/api/v4` group-projects
//! endpoint, and rejects malformed input, all *before* any socket is opened.
//!
//! This file is deliberately DISTINCT from `regression_hosted_git_endpoint.rs`
//! (which drives the loopback `/api/v4` mock and the endpoint scheme/userinfo/
//! query refusals) and from GitHub coverage (GitHub uses `validate_org_name`
//! with a 39-char limit + leading/trailing-hyphen rule + "unsafe characters";
//! GitLab uses `validate_group_path` -> per-segment `validate_repo_name` with a
//! 100-char cap, `..`/separator refusal, and "non-alphanumeric" charset).
//!
//! Every assertion pins a CONCRETE value: the exact `Ok(())` variant, the exact
//! `SourceError::Other` refusal string (built with the same `{:?}` formatting the
//! production code uses, so the expected value is deterministic), the exact
//! factory arity error, or the exact source name. No accelerator, no git binary,
//! and no network are required, the group-path validator and the factory param
//! parser are pure functions reached through the crate's public API and its
//! `#[doc(hidden)]` testing facade.
#![cfg(feature = "gitlab")]
use keyhog_core::{Source, SourceError};
use keyhog_sources::create_source;
use keyhog_sources::testing::{SourceTestApi, TestApi};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Pin an acceptance to the exact `Ok(())` variant. `SourceError` is not
/// `PartialEq`, so a match (not `assert_eq!(_, Ok(()))`) is how acceptance is
/// bound to a concrete value.
fn assert_group_accepted(group: &str) {
match TestApi.validate_gitlab_group_path(group) {
Ok(()) => {}
Err(err) => panic!("group {group:?} must be accepted, got refusal: {err}"),
}
}
/// Return the inner `SourceError::Other` message for a refused group path,
/// asserting the error variant is exactly `Other` (never `Io`/`Git`).
fn group_refusal_message(group: &str) -> String {
match TestApi.validate_gitlab_group_path(group) {
Ok(()) => panic!("group {group:?} must be refused, but validation accepted it"),
Err(SourceError::Other(msg)) => msg,
Err(other) => panic!("group {group:?} refusal must be SourceError::Other, got: {other:?}"),
}
}
// ---------------------------------------------------------------------------
// Namespace (group path) classification, positive
// ---------------------------------------------------------------------------
#[test]
fn group_path_accepts_flat_nested_and_charset_namespaces() {
// Flat namespace, a two-segment subgroup namespace, and the full
// [A-Za-z0-9._-] segment alphabet must all classify as valid GitLab groups.
assert_group_accepted("santhsecurity");
assert_group_accepted("platform/sub-group");
assert_group_accepted("platform/sub-group/team-alpha");
assert_group_accepted("a.b_c-d");
}
// ---------------------------------------------------------------------------
// Namespace classification, negative twins (exact refusal strings)
// ---------------------------------------------------------------------------
#[test]
fn group_path_rejects_empty_with_exact_message() {
let msg = group_refusal_message("");
assert_eq!(
msg,
format!(
"gitlab: refusing group path with invalid length or slash placement: {:?}",
""
),
);
}
#[test]
fn group_path_rejects_leading_slash() {
let msg = group_refusal_message("/root");
assert_eq!(
msg,
format!(
"gitlab: refusing group path with invalid length or slash placement: {:?}",
"/root"
),
);
}
#[test]
fn group_path_rejects_trailing_slash() {
let msg = group_refusal_message("root/");
assert_eq!(
msg,
format!(
"gitlab: refusing group path with invalid length or slash placement: {:?}",
"root/"
),
);
}
#[test]
fn group_path_rejects_empty_middle_segment_zero_length() {
// `root//child` splits to ["root", "", "child"]; the empty middle segment is
// a zero-length repo name, refused by the per-segment length guard. NOT the
// top-level slash-placement guard (which only checks leading/trailing).
let msg = group_refusal_message("root//child");
assert_eq!(
msg,
"gitlab: refusing repo with out-of-range name length (0)".to_string(),
);
}
#[test]
fn group_path_rejects_dotdot_traversal_segment() {
// `..` as a namespace segment is a path-traversal gadget into the temp clone
// root and must be refused by name, not silently normalized.
let msg = group_refusal_message("../root");
assert_eq!(
msg,
format!(
"gitlab: refusing repo with traversal/separator in name: {:?}",
".."
),
);
}
#[test]
fn group_path_rejects_backslash_segment() {
// A backslash inside a single segment (no '/' to split on) is a Windows
// separator; the segment validator refuses it as a traversal/separator.
let group = "my\\group";
let msg = group_refusal_message(group);
assert_eq!(
msg,
format!(
"gitlab: refusing repo with traversal/separator in name: {:?}",
group
),
);
}
#[test]
fn group_path_rejects_space_and_non_ascii_charset() {
// A space is outside the [A-Za-z0-9._-] alphabet.
let spaced = "root child";
assert_eq!(
group_refusal_message(spaced),
format!(
"gitlab: refusing repo with non-alphanumeric name: {:?}",
spaced
),
);
// A non-ASCII letter is likewise refused (no Unicode-alphanumeric widening).
let unicode = "grp\u{00e9}"; // "grpé"
assert_eq!(
group_refusal_message(unicode),
format!(
"gitlab: refusing repo with non-alphanumeric name: {:?}",
unicode
),
);
}
// ---------------------------------------------------------------------------
// Namespace classification, boundaries
// ---------------------------------------------------------------------------
#[test]
fn group_path_segment_length_boundary_100_ok_101_rejected() {
// 100 chars is the inclusive maximum for a single namespace segment.
assert_group_accepted(&"a".repeat(100));
// 101 chars is refused, and the diagnostic reports the exact over-limit length.
let msg = group_refusal_message(&"a".repeat(101));
assert_eq!(
msg,
"gitlab: refusing repo with out-of-range name length (101)".to_string(),
);
}
#[test]
fn group_path_total_length_boundary_over_512_rejected() {
// A multi-segment namespace that is long but within all limits is accepted:
// five 100-char segments joined by '/' = 504 bytes, each segment <= 100.
let long_ok = vec!["a".repeat(100); 5].join("/");
assert_eq!(long_ok.len(), 504, "test corpus sanity: joined length");
assert_group_accepted(&long_ok);
// A 513-byte group trips the top-level total-length guard *before* segment
// splitting, so the refusal is the group-path (not per-repo) message.
let msg = group_refusal_message(&"a".repeat(513));
assert!(
msg.starts_with("gitlab: refusing group path with invalid length or slash placement:"),
"over-512 group must hit the group-path length guard, got: {msg}"
);
}
// ---------------------------------------------------------------------------
// Partial-coverage (listing truncation) classification
// ---------------------------------------------------------------------------
#[test]
fn listing_truncated_error_exact_message_and_variant() {
// The page cap turns a partial GitLab group listing into a loud refusal so
// unseen projects are never silently reported clean. Platform is "GitLab",
// owner-kind is "group"; the numbers are echoed verbatim.
let err = TestApi.gitlab_group_listing_truncated_error("santhsecurity", 250, 3);
match err {
SourceError::Other(msg) => assert_eq!(
msg,
"GitLab group repository listing for santhsecurity exceeded 3 pages \
(250 repositories); refusing to scan a partial group repository collection \
because unseen repositories would be reported clean"
.to_string(),
),
other => panic!("truncation error must be SourceError::Other, got: {other:?}"),
}
}
// ---------------------------------------------------------------------------
// Factory param classification (arity), distinct from endpoint validation
// ---------------------------------------------------------------------------
#[test]
fn factory_missing_params_returns_exact_arity_error() {
match create_source("gitlab-group", None) {
Ok(_) => panic!("gitlab-group with no params must be refused"),
Err(SourceError::InvalidConfiguration {
source_name,
detail,
}) => {
assert_eq!(source_name, "gitlab-group");
assert_eq!(
detail,
"GROUP and TOKEN parameters are required; ENDPOINT is optional"
);
}
Err(other) => panic!("expected typed configuration error, got: {other:?}"),
}
}
#[test]
fn factory_missing_token_field_rejected() {
// A single line (group only, no '\n' token field) must be refused.
match create_source("gitlab-group", Some("onlygroup")) {
Ok(_) => panic!("gitlab-group without a token field must be refused"),
Err(SourceError::InvalidConfiguration {
source_name,
detail,
}) => {
assert_eq!(source_name, "gitlab-group");
assert_eq!(detail, "group and token parameters are required");
}
Err(other) => panic!("expected typed configuration error, got: {other:?}"),
}
}
#[test]
fn factory_empty_token_field_rejected() {
// Present-but-empty token field ("grp\n") is refused by the emptiness check.
match create_source("gitlab-group", Some("grp\n")) {
Ok(_) => panic!("gitlab-group with an empty token must be refused"),
Err(SourceError::InvalidConfiguration {
source_name,
detail,
}) => {
assert_eq!(source_name, "gitlab-group");
assert_eq!(detail, "group and token parameters are required");
}
Err(other) => panic!("expected typed configuration error, got: {other:?}"),
}
}
#[test]
fn factory_empty_group_field_rejected() {
// Empty group field ("\ntok") is refused by the same emptiness check.
match create_source("gitlab-group", Some("\ntok")) {
Ok(_) => panic!("gitlab-group with an empty group must be refused"),
Err(SourceError::InvalidConfiguration {
source_name,
detail,
}) => {
assert_eq!(source_name, "gitlab-group");
assert_eq!(detail, "group and token parameters are required");
}
Err(other) => panic!("expected typed configuration error, got: {other:?}"),
}
}
#[test]
fn factory_constructs_source_and_reports_stable_name() {
// Valid group+token constructs (no network, no endpoint validation yet) and
// the source name is the stable "gitlab-group" plugin identifier.
let dashed = create_source("gitlab-group", Some("grp\ntok"))
.expect("valid group/token constructs a gitlab-group source");
assert_eq!(dashed.name(), "gitlab-group");
match create_source(
"gitlab_group",
Some("grp\ntok\nhttps://gitlab.internal.example"),
) {
Err(SourceError::DeprecatedSourceName { name, replacement }) => {
assert_eq!(name, "gitlab_group");
assert_eq!(replacement, "gitlab-group");
}
Err(other) => panic!("underscore alias returned the wrong error: {other:?}"),
Ok(_) => panic!("underscore alias must not be accepted silently"),
}
}
#[test]
fn factory_unknown_gitlab_like_plugin_rejected() {
// A look-alike plugin name is refused with the exact unknown-plugin error,
// naming the offending plugin.
match create_source("gitlab-project", None) {
Ok(_) => panic!("unknown plugin name must be refused"),
Err(SourceError::UnknownSource { name }) => {
assert_eq!(name, "gitlab-project")
}
Err(other) => panic!("expected typed unknown-source error, got: {other:?}"),
}
}