Skip to content

Commit 0912865

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 <heyuming@deepin.org>
1 parent b9526b3 commit 0912865

1 file changed

Lines changed: 333 additions & 0 deletions

File tree

accounts1/users/validation_test.go

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

0 commit comments

Comments
 (0)