Skip to content

Commit 021b6c5

Browse files
committed
test(users): add validation tests for isValidUsername and isValidCryptHash
Cover all 16 crypt(5) hash methods, forbidden delimiter positions, and shadow-utils username rules with table-driven and fuzz tests. Signed-off-by: Yuming He <ComixHe1895@outlook.com>
1 parent 6b7a098 commit 021b6c5

1 file changed

Lines changed: 329 additions & 0 deletions

File tree

accounts1/users/validation_test.go

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
package users
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestIsValidUsername(t *testing.T) {
9+
maxLen := LoginNameMaxSize()
10+
11+
tests := []struct {
12+
name string
13+
input string
14+
wantErr bool
15+
}{
16+
{"lowercase", "root", false},
17+
{"uppercase", "ADMIN", false},
18+
{"mixed case", "JohnDoe", false},
19+
{"with digits", "user123", false},
20+
{"digit first then letter", "0xroot", false},
21+
{"underscore in middle", "my_user", false},
22+
{"underscore first", "_start", false},
23+
{"single underscore", "_", false},
24+
{"dot in middle", "user.name", false},
25+
{"dot first", ".hidden", false},
26+
{"dash in middle", "my-user", false},
27+
{"dash at end", "user-", false},
28+
{"trailing dollar sign", "computer$", false},
29+
{"trailing dollar with dash", "my-user$", false},
30+
{"single letter", "a", false},
31+
{"max length valid", strings.Repeat("a", maxLen), false},
32+
{"all valid chars", "aB3_.-z", false},
33+
{"all valid chars trailing dollar", "aB3_.-z$", false},
34+
{"dot dot word", "..foo", false},
35+
{"digit then trailing dollar", "1$", false},
36+
{"underscore then trailing dollar", "_$", false},
37+
{"max length minus one plus dollar", strings.Repeat("a", maxLen-1) + "$", false},
38+
39+
{"empty string", "", true},
40+
{"single dot", ".", true},
41+
{"double dot", "..", true},
42+
43+
{"dash first", "-user", true},
44+
{"single dash", "-", true},
45+
{"all dashes", "---", true},
46+
47+
{"all digits", "12345", true},
48+
{"single digit zero", "0", true},
49+
{"single digit nine", "9", true},
50+
51+
{"dollar in middle", "u$er", true},
52+
{"dollar at start", "$user", true},
53+
{"multiple dollars", "u$e$r", true},
54+
{"isolated dollar", "$", true},
55+
{"exceeds max length with trailing dollar", strings.Repeat("a", maxLen) + "$", true},
56+
{"max length with trailing dollar to match maxLen", strings.Repeat("a", maxLen-1) + "$", false},
57+
58+
{"contains space", "user name", true},
59+
{"contains colon", "user:name", true},
60+
{"contains at sign", "user@host", true},
61+
{"contains newline", "user\nname", true},
62+
{"contains carriage return", "user\rname", true},
63+
{"contains tab", "user\tname", true},
64+
{"contains slash", "user/name", true},
65+
{"contains backslash", "user\\name", true},
66+
{"contains semicolon", "user;name", true},
67+
{"contains hash", "user#name", true},
68+
{"contains exclamation", "user!", true},
69+
{"contains asterisk", "user*", true},
70+
{"contains null byte", "user\x00name", true},
71+
{"contains DEL", "user\x7fname", true},
72+
{"non-ASCII UTF-8", "usér", true},
73+
74+
{"exceeds max length", strings.Repeat("a", maxLen+1), true},
75+
}
76+
77+
for _, tt := range tests {
78+
t.Run(tt.name, func(t *testing.T) {
79+
err := isValidUsername(tt.input)
80+
if (err != nil) != tt.wantErr {
81+
if tt.wantErr {
82+
t.Errorf("isValidUsername(%q) = nil; want error", tt.input)
83+
} else {
84+
t.Errorf("isValidUsername(%q) = %v; want nil", tt.input, err)
85+
}
86+
}
87+
})
88+
}
89+
}
90+
91+
func TestIsValidCryptHash(t *testing.T) {
92+
tests := []struct {
93+
name string
94+
input string
95+
wantErr bool
96+
}{
97+
// Valid: descrypt — [./0-9A-Za-z]{13}
98+
{"descrypt", "rEK1ecacwY7Ec", false},
99+
100+
// Valid: bsdicrypt — _[./0-9A-Za-z]{19}
101+
{"bsdicrypt", "_J9..CCCCXBrJUQKYwfM", false},
102+
103+
// Valid: md5crypt — $1$[^$:\n]{1,8}$[./0-9A-Za-z]{22}
104+
{"md5crypt", "$1$5heVhQ1S$6Jv5CZTPb5bEidVHKMLYQ0", false},
105+
106+
// Valid: sha256crypt — $5$salt$hash
107+
{"sha256crypt", "$5$rounds=5000$saltsalt$Qd1q7XbC7pFRCXbmJ4zBvqJK9yB0KV.YHqyLHtQ8Hj5", false},
108+
109+
// Valid: sha512crypt — $6$salt$hash
110+
{"sha512crypt", "$6$rounds=5000$saltsalt$WV2zoZJ0V2rFPt.mqU0bJ5NqOq0Pw/T6b62Cn9LdWmGdQhv5KjI6q0Q1YqW3Yd0F9fZEPNi3s3xOt4y1wX5K.", false},
111+
112+
// Valid: bcrypt — $2b$10$salthash
113+
{"bcrypt", "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", false},
114+
115+
// Valid: bcrypt variant $2a$
116+
{"bcrypt 2a", "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", false},
117+
118+
// Valid: bcrypt variant $2y$
119+
{"bcrypt 2y", "$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", false},
120+
121+
// Valid: yescrypt — $y$...
122+
{"yescrypt", "$y$j9T$salt$wu3F0f0Y.0xRfZb7YUvXRMFOXJymrV3NWGKvhjqYUBC", false},
123+
124+
// Valid: gost-yescrypt — $gy$...
125+
{"gost-yescrypt", "$gy$j9T$salt$wu3F0f0Y.0xRfZb7YUvXRMFOXJymrV3NWGKvhjqYUBC", false},
126+
127+
// Valid: scrypt — $7$...
128+
{"scrypt", "$7$C6..../....hF6U8VWl2Sg5OHa1/$s2MLYiJZyW9b8g1k01cz3yBDEsSja8ruRhZJvM0D7t4", false},
129+
130+
// Valid: sha1crypt — $sha1$...
131+
{"sha1crypt", "$sha1$12345$FBELCwsB$M6FRylBqD9nKgSXSbLIs2MsjD3NmC", false},
132+
133+
// Valid: SunMD5 — $md5...
134+
{"sunmd5", "$md5,rounds=12345$UBUqeiUQ$mQVVoVfN03ZMcK3gRl0kS1", false},
135+
136+
// Valid: sm3crypt — $sm3$...
137+
{"sm3crypt", "$sm3$rounds=5000$saltsalt$WV2zoZJ0V2rFPt.mqU0bJ5NqOq0Pw/T6b62Cn9LdWmGdQhv5KjI6q0Q1YqW3Yd0F9fZEPNi3s3xOt4y1wX5K.", false},
138+
139+
// Valid: sm3-yescrypt — $sm3y$...
140+
{"sm3yescrypt", "$sm3y$j9T$salt$wu3F0f0Y.0xRfZb7YUvXRMFOXJymrV3NWGKvhjqYUBC", false},
141+
142+
// Valid: NT — $3$$hex
143+
{"nt", "$3$$8846f7eaee8fb117ad96bdd264e92957", false},
144+
145+
// Valid: edge cases with allowed chars
146+
{"single dollar", "$", false},
147+
{"dollar dot slash", "$./", false},
148+
{"only base64 crypt chars", "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", false},
149+
{"single printable char", "a", false},
150+
{"underscore prefix bsdicrypt style", "_validHashChars123", false},
151+
152+
// Invalid: empty
153+
{"empty", "", true},
154+
155+
// Invalid: forbidden chars per crypt(5) — whitespace
156+
{"contains space", "$6$salt$hash with space", true},
157+
{"contains tab", "$6$salt$hash\ttab", true},
158+
{"contains newline", "$6$salt$hash\nnewline", true},
159+
{"contains carriage return", "$6$salt$hash\rcr", true},
160+
161+
// Invalid: forbidden chars per crypt(5) — delimiters
162+
{"contains colon", "$6$salt$hash:colon", true},
163+
{"contains semicolon", "$6$salt$hash;semicolon", true},
164+
{"contains asterisk", "$6$salt$hash*asterisk", true},
165+
{"contains exclamation", "$6$salt$hash!bang", true},
166+
{"contains backslash", "$6$salt$hash\\backslash", true},
167+
168+
// Invalid: non-printable ASCII
169+
{"contains null byte", "$6$salt$\x00hash", true},
170+
{"contains DEL", "$6$salt$\x7fhash", true},
171+
{"contains control char 0x01", "$6$salt$\x01hash", true},
172+
{"contains control char 0x1f", "$6$salt$\x1fhash", true},
173+
174+
// Invalid: non-ASCII
175+
{"non-ASCII UTF-8", "$6$salt$häsH", true},
176+
{"high byte 0x80", "$6$salt$\x80hash", true},
177+
{"high byte 0xff", "$6$salt$\xffhash", true},
178+
179+
// Invalid: forbidden char at start
180+
{"colon at start", ":hash", true},
181+
{"semicolon at start", ";hash", true},
182+
{"asterisk at start", "*hash", true},
183+
{"exclamation at start", "!hash", true},
184+
{"backslash at start", "\\hash", true},
185+
{"space at start", " hash", true},
186+
187+
// Invalid: forbidden char at end
188+
{"colon at end", "$6$salt$hash:", true},
189+
{"semicolon at end", "$6$salt$hash;", true},
190+
{"asterisk at end", "$6$salt$hash*", true},
191+
{"exclamation at end", "$6$salt$hash!", true},
192+
{"backslash at end", "$6$salt$hash\\", true},
193+
{"space at end", "$6$salt$hash ", true},
194+
}
195+
196+
for _, tt := range tests {
197+
t.Run(tt.name, func(t *testing.T) {
198+
err := isValidCryptHash(tt.input)
199+
if (err != nil) != tt.wantErr {
200+
if tt.wantErr {
201+
t.Errorf("isValidCryptHash(%q) = nil; want error", tt.input)
202+
} else {
203+
t.Errorf("isValidCryptHash(%q) = %v; want nil", tt.input, err)
204+
}
205+
}
206+
})
207+
}
208+
}
209+
210+
func FuzzIsValidCryptHash(f *testing.F) {
211+
f.Add("$6$rounds=5000$saltsalt$Qd1q7XbC7pFRCXbmJ4zBvqJK9yB0KV.YHqyLHtQ8Hj5")
212+
f.Add("$1$5heVhQ1S$6Jv5CZTPb5bEidVHKMLYQ0")
213+
f.Add("$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy")
214+
f.Add("rEK1ecacwY7Ec")
215+
f.Add("_J9..CCCCXBrJUQKYwfM")
216+
f.Add("$3$$8846f7eaee8fb117ad96bdd264e92957")
217+
f.Add("")
218+
f.Add("hash with space")
219+
f.Add("hash:colon")
220+
f.Add("hash;semi")
221+
f.Add("hash*star")
222+
f.Add("hash!bang")
223+
f.Add("hash\\back")
224+
f.Add("hash\x00null")
225+
f.Add("häsH")
226+
227+
f.Fuzz(func(t *testing.T, hash string) {
228+
err := isValidCryptHash(hash)
229+
if err != nil {
230+
return
231+
}
232+
233+
if hash == "" {
234+
t.Errorf("accepted empty hash")
235+
}
236+
237+
forbidden := [6]byte{' ', ':', ';', '*', '!', '\\'}
238+
for i := 0; i < len(hash); i++ {
239+
b := hash[i]
240+
241+
if b < 32 || b > 126 {
242+
t.Errorf("accepted non-printable byte 0x%02x at pos %d", b, i)
243+
}
244+
245+
for _, f := range forbidden {
246+
if b == f {
247+
t.Errorf("accepted forbidden byte 0x%02x (%c) at pos %d", b, b, i)
248+
}
249+
}
250+
}
251+
})
252+
}
253+
254+
func FuzzIsValidUsername(f *testing.F) {
255+
f.Add("root")
256+
f.Add("user-name")
257+
f.Add(".")
258+
f.Add("-bad")
259+
f.Add("123")
260+
f.Add("a$b")
261+
f.Add("computer$")
262+
f.Add("")
263+
f.Add("..")
264+
f.Add("_underscore")
265+
f.Add("0xstart")
266+
f.Add("a")
267+
f.Add("1$")
268+
f.Add("_$")
269+
f.Add("$")
270+
271+
f.Fuzz(func(t *testing.T, name string) {
272+
err := isValidUsername(name)
273+
if err != nil {
274+
return
275+
}
276+
277+
if name == "" || name == "." || name == ".." {
278+
t.Fatalf("accepted forbidden name %q", name)
279+
}
280+
281+
if len(name) > LoginNameMaxSize() {
282+
t.Fatalf("accepted name exceeding max length (%d bytes)", len(name))
283+
}
284+
285+
if len(name) > 0 && name[0] == '-' {
286+
t.Fatalf("accepted dash-first name %q", name)
287+
}
288+
289+
allDigit := true
290+
for i := 0; i < len(name); i++ {
291+
if name[i] < '0' || name[i] > '9' {
292+
allDigit = false
293+
break
294+
}
295+
}
296+
297+
if allDigit && len(name) > 0 {
298+
t.Fatalf("accepted all-numeric name %q", name)
299+
}
300+
301+
for i := 0; i < len(name); i++ {
302+
b := name[i]
303+
valid := (b >= 'a' && b <= 'z') ||
304+
(b >= 'A' && b <= 'Z') ||
305+
(b >= '0' && b <= '9') ||
306+
b == '_' || b == '.' || b == '-'
307+
308+
if b == '$' && i == len(name)-1 {
309+
valid = true
310+
}
311+
312+
if !valid {
313+
t.Fatalf("accepted invalid byte 0x%02x at pos %d in %q", b, i, name)
314+
}
315+
}
316+
317+
if len(name) > 0 {
318+
b := name[0]
319+
firstValid := (b >= 'a' && b <= 'z') ||
320+
(b >= 'A' && b <= 'Z') ||
321+
(b >= '0' && b <= '9') ||
322+
b == '_' || b == '.'
323+
324+
if !firstValid {
325+
t.Fatalf("accepted invalid first byte 0x%02x in %q", b, name)
326+
}
327+
}
328+
})
329+
}

0 commit comments

Comments
 (0)