Skip to content

Commit 2e43671

Browse files
(New validator) Validate non-existing but valid file/directory paths (#1022)
1 parent f560fd4 commit 2e43671

File tree

4 files changed

+226
-14
lines changed

4 files changed

+226
-14
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,10 @@ Baked-in Validations
222222
### Other:
223223
| Tag | Description |
224224
| - | - |
225-
| dir | Directory |
226-
| file | File path |
225+
| dir | Existing Directory |
226+
| dirpath | Directory Path |
227+
| file | Existing File |
228+
| filepath | File Path |
227229
| isdefault | Is Default |
228230
| len | Length |
229231
| max | Maximum |

baked_in.go

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,22 @@ import (
77
"encoding/hex"
88
"encoding/json"
99
"fmt"
10+
"io/fs"
1011
"net"
1112
"net/url"
1213
"os"
1314
"reflect"
1415
"strconv"
1516
"strings"
1617
"sync"
18+
"syscall"
1719
"time"
1820
"unicode/utf8"
1921

2022
"golang.org/x/crypto/sha3"
2123
"golang.org/x/text/language"
2224

23-
urn "github.com/leodido/go-urn"
25+
"github.com/leodido/go-urn"
2426
)
2527

2628
// Func accepts a FieldLevel interface for all validation needs. The return
@@ -127,6 +129,7 @@ var (
127129
"uri": isURI,
128130
"urn_rfc2141": isUrnRFC2141, // RFC 2141
129131
"file": isFile,
132+
"filepath": isFilePath,
130133
"base64": isBase64,
131134
"base64url": isBase64URL,
132135
"base64rawurl": isBase64RawURL,
@@ -199,6 +202,7 @@ var (
199202
"html_encoded": isHTMLEncoded,
200203
"url_encoded": isURLEncoded,
201204
"dir": isDir,
205+
"dirpath": isDirPath,
202206
"json": isJSON,
203207
"jwt": isJWT,
204208
"hostname_port": isHostnamePort,
@@ -1464,7 +1468,7 @@ func isUrnRFC2141(fl FieldLevel) bool {
14641468
panic(fmt.Sprintf("Bad field type %T", field.Interface()))
14651469
}
14661470

1467-
// isFile is the validation function for validating if the current field's value is a valid file path.
1471+
// isFile is the validation function for validating if the current field's value is a valid existing file path.
14681472
func isFile(fl FieldLevel) bool {
14691473
field := fl.Field()
14701474

@@ -1481,6 +1485,57 @@ func isFile(fl FieldLevel) bool {
14811485
panic(fmt.Sprintf("Bad field type %T", field.Interface()))
14821486
}
14831487

1488+
// isFilePath is the validation function for validating if the current field's value is a valid file path.
1489+
func isFilePath(fl FieldLevel) bool {
1490+
1491+
var exists bool
1492+
var err error
1493+
1494+
field := fl.Field()
1495+
1496+
// If it exists, it obviously is valid.
1497+
// This is done first to avoid code duplication and unnecessary additional logic.
1498+
if exists = isFile(fl); exists {
1499+
return true
1500+
}
1501+
1502+
// It does not exist but may still be a valid filepath.
1503+
switch field.Kind() {
1504+
case reflect.String:
1505+
// Every OS allows for whitespace, but none
1506+
// let you use a file with no filename (to my knowledge).
1507+
// Unless you're dealing with raw inodes, but I digress.
1508+
if strings.TrimSpace(field.String()) == "" {
1509+
return false
1510+
}
1511+
// We make sure it isn't a directory.
1512+
if strings.HasSuffix(field.String(), string(os.PathSeparator)) {
1513+
return false
1514+
}
1515+
if _, err = os.Stat(field.String()); err != nil {
1516+
switch t := err.(type) {
1517+
case *fs.PathError:
1518+
if t.Err == syscall.EINVAL {
1519+
// It's definitely an invalid character in the filepath.
1520+
return false
1521+
}
1522+
// It could be a permission error, a does-not-exist error, etc.
1523+
// Out-of-scope for this validation, though.
1524+
return true
1525+
default:
1526+
// Something went *seriously* wrong.
1527+
/*
1528+
Per https://pkg.go.dev/os#Stat:
1529+
"If there is an error, it will be of type *PathError."
1530+
*/
1531+
panic(err)
1532+
}
1533+
}
1534+
}
1535+
1536+
panic(fmt.Sprintf("Bad field type %T", field.Interface()))
1537+
}
1538+
14841539
// isE164 is the validation function for validating if the current field's value is a valid e.164 formatted phone number.
14851540
func isE164(fl FieldLevel) bool {
14861541
return e164Regex.MatchString(fl.Field().String())
@@ -2354,7 +2409,7 @@ func isFQDN(fl FieldLevel) bool {
23542409
return fqdnRegexRFC1123.MatchString(val)
23552410
}
23562411

2357-
// isDir is the validation function for validating if the current field's value is a valid directory.
2412+
// isDir is the validation function for validating if the current field's value is a valid existing directory.
23582413
func isDir(fl FieldLevel) bool {
23592414
field := fl.Field()
23602415

@@ -2370,6 +2425,64 @@ func isDir(fl FieldLevel) bool {
23702425
panic(fmt.Sprintf("Bad field type %T", field.Interface()))
23712426
}
23722427

2428+
// isDirPath is the validation function for validating if the current field's value is a valid directory.
2429+
func isDirPath(fl FieldLevel) bool {
2430+
2431+
var exists bool
2432+
var err error
2433+
2434+
field := fl.Field()
2435+
2436+
// If it exists, it obviously is valid.
2437+
// This is done first to avoid code duplication and unnecessary additional logic.
2438+
if exists = isDir(fl); exists {
2439+
return true
2440+
}
2441+
2442+
// It does not exist but may still be a valid path.
2443+
switch field.Kind() {
2444+
case reflect.String:
2445+
// Every OS allows for whitespace, but none
2446+
// let you use a dir with no name (to my knowledge).
2447+
// Unless you're dealing with raw inodes, but I digress.
2448+
if strings.TrimSpace(field.String()) == "" {
2449+
return false
2450+
}
2451+
if _, err = os.Stat(field.String()); err != nil {
2452+
switch t := err.(type) {
2453+
case *fs.PathError:
2454+
if t.Err == syscall.EINVAL {
2455+
// It's definitely an invalid character in the path.
2456+
return false
2457+
}
2458+
// It could be a permission error, a does-not-exist error, etc.
2459+
// Out-of-scope for this validation, though.
2460+
// Lastly, we make sure it is a directory.
2461+
if strings.HasSuffix(field.String(), string(os.PathSeparator)) {
2462+
return true
2463+
} else {
2464+
return false
2465+
}
2466+
default:
2467+
// Something went *seriously* wrong.
2468+
/*
2469+
Per https://pkg.go.dev/os#Stat:
2470+
"If there is an error, it will be of type *PathError."
2471+
*/
2472+
panic(err)
2473+
}
2474+
}
2475+
// We repeat the check here to make sure it is an explicit directory in case the above os.Stat didn't trigger an error.
2476+
if strings.HasSuffix(field.String(), string(os.PathSeparator)) {
2477+
return true
2478+
} else {
2479+
return false
2480+
}
2481+
}
2482+
2483+
panic(fmt.Sprintf("Bad field type %T", field.Interface()))
2484+
}
2485+
23732486
// isJSON is the validation function for validating if the current field's value is a valid json string.
23742487
func isJSON(fl FieldLevel) bool {
23752488
field := fl.Field()

doc.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -863,14 +863,25 @@ This validates that a string value is a valid JWT
863863
864864
Usage: jwt
865865
866-
# File path
866+
867+
# File
867868
868869
This validates that a string value contains a valid file path and that
869870
the file exists on the machine.
870871
This is done using os.Stat, which is a platform independent function.
871872
872873
Usage: file
873874
875+
876+
# File Path
877+
878+
This validates that a string value contains a valid file path but does not
879+
validate the existence of that file.
880+
This is done using os.Stat, which is a platform independent function.
881+
882+
Usage: filepath
883+
884+
874885
# URL String
875886
876887
This validates that a string value contains a valid url
@@ -912,6 +923,7 @@ you can use this with the omitempty tag.
912923
913924
Usage: base64url
914925
926+
915927
# Base64RawURL String
916928
917929
This validates that a string value contains a valid base64 URL safe value,
@@ -922,6 +934,7 @@ you can use this with the omitempty tag.
922934
923935
Usage: base64url
924936
937+
925938
# Bitcoin Address
926939
927940
This validates that a string value contains a valid bitcoin address.
@@ -1254,6 +1267,18 @@ This is done using os.Stat, which is a platform independent function.
12541267
12551268
Usage: dir
12561269
1270+
1271+
# Directory Path
1272+
1273+
This validates that a string value contains a valid directory but does
1274+
not validate the existence of that directory.
1275+
This is done using os.Stat, which is a platform independent function.
1276+
It is safest to suffix the string with os.PathSeparator if the directory
1277+
may not exist at the time of validation.
1278+
1279+
Usage: dirpath
1280+
1281+
12571282
# HostPort
12581283
12591284
This validates that a string value contains a valid DNS hostname and port that

validator_test.go

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"encoding/base64"
99
"encoding/json"
1010
"fmt"
11+
"os"
1112
"path/filepath"
1213
"reflect"
1314
"strings"
@@ -3819,12 +3820,14 @@ func TestDataURIValidation(t *testing.T) {
38193820
{"data:image/png;base64,TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4=", true},
38203821
{"data:text/plain;base64,Vml2YW11cyBmZXJtZW50dW0gc2VtcGVyIHBvcnRhLg==", true},
38213822
{"image/gif;base64,U3VzcGVuZGlzc2UgbGVjdHVzIGxlbw==", false},
3822-
{"data:image/gif;base64,MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMPNS1Ufof9EW/M98FNw" +
3823-
"UAKrwflsqVxaxQjBQnHQmiI7Vac40t8x7pIb8gLGV6wL7sBTJiPovJ0V7y7oc0Ye" +
3824-
"rhKh0Rm4skP2z/jHwwZICgGzBvA0rH8xlhUiTvcwDCJ0kc+fh35hNt8srZQM4619" +
3825-
"FTgB66Xmp4EtVyhpQV+t02g6NzK72oZI0vnAvqhpkxLeLiMCyrI416wHm5Tkukhx" +
3826-
"QmcL2a6hNOyu0ixX/x2kSFXApEnVrJ+/IxGyfyw8kf4N2IZpW5nEP847lpfj0SZZ" +
3827-
"Fwrd1mnfnDbYohX2zRptLy2ZUn06Qo9pkG5ntvFEPo9bfZeULtjYzIl6K8gJ2uGZ" + "HQIDAQAB", true},
3823+
{
3824+
"data:image/gif;base64,MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMPNS1Ufof9EW/M98FNw" +
3825+
"UAKrwflsqVxaxQjBQnHQmiI7Vac40t8x7pIb8gLGV6wL7sBTJiPovJ0V7y7oc0Ye" +
3826+
"rhKh0Rm4skP2z/jHwwZICgGzBvA0rH8xlhUiTvcwDCJ0kc+fh35hNt8srZQM4619" +
3827+
"FTgB66Xmp4EtVyhpQV+t02g6NzK72oZI0vnAvqhpkxLeLiMCyrI416wHm5Tkukhx" +
3828+
"QmcL2a6hNOyu0ixX/x2kSFXApEnVrJ+/IxGyfyw8kf4N2IZpW5nEP847lpfj0SZZ" +
3829+
"Fwrd1mnfnDbYohX2zRptLy2ZUn06Qo9pkG5ntvFEPo9bfZeULtjYzIl6K8gJ2uGZ" + "HQIDAQAB", true,
3830+
},
38283831
{"data:image/png;base64,12345", false},
38293832
{"", false},
38303833
{"data:text,:;base85,U3VzcGVuZGlzc2UgbGVjdHVzIGxlbw==", false},
@@ -5732,6 +5735,39 @@ func TestFileValidation(t *testing.T) {
57325735
}, "Bad field type int")
57335736
}
57345737

5738+
func TestFilePathValidation(t *testing.T) {
5739+
validate := New()
5740+
5741+
tests := []struct {
5742+
title string
5743+
param string
5744+
expected bool
5745+
}{
5746+
{"empty filepath", "", false},
5747+
{"valid filepath", filepath.Join("testdata", "a.go"), true},
5748+
{"invalid filepath", filepath.Join("testdata", "no\000.go"), false},
5749+
{"directory, not a filepath", "testdata" + string(os.PathSeparator), false},
5750+
}
5751+
5752+
for _, test := range tests {
5753+
errs := validate.Var(test.param, "filepath")
5754+
5755+
if test.expected {
5756+
if !IsEqual(errs, nil) {
5757+
t.Fatalf("Test: '%s' failed Error: %s", test.title, errs)
5758+
}
5759+
} else {
5760+
if IsEqual(errs, nil) {
5761+
t.Fatalf("Test: '%s' failed Error: %s", test.title, errs)
5762+
}
5763+
}
5764+
}
5765+
5766+
PanicMatches(t, func() {
5767+
_ = validate.Var(6, "filepath")
5768+
}, "Bad field type int")
5769+
}
5770+
57355771
func TestEthereumAddressValidation(t *testing.T) {
57365772
validate := New()
57375773

@@ -10569,6 +10605,40 @@ func TestDirValidation(t *testing.T) {
1056910605
}, "Bad field type int")
1057010606
}
1057110607

10608+
func TestDirPathValidation(t *testing.T) {
10609+
validate := New()
10610+
10611+
tests := []struct {
10612+
title string
10613+
param string
10614+
expected bool
10615+
}{
10616+
{"empty dirpath", "", false},
10617+
{"valid dirpath - exists", "testdata", true},
10618+
{"valid dirpath - explicit", "testdatanoexist" + string(os.PathSeparator), true},
10619+
{"invalid dirpath", "testdata\000" + string(os.PathSeparator), false},
10620+
{"file, not a dirpath", filepath.Join("testdata", "a.go"), false},
10621+
}
10622+
10623+
for _, test := range tests {
10624+
errs := validate.Var(test.param, "dirpath")
10625+
10626+
if test.expected {
10627+
if !IsEqual(errs, nil) {
10628+
t.Fatalf("Test: '%s' failed Error: %s", test.title, errs)
10629+
}
10630+
} else {
10631+
if IsEqual(errs, nil) {
10632+
t.Fatalf("Test: '%s' failed Error: %s", test.title, errs)
10633+
}
10634+
}
10635+
}
10636+
10637+
PanicMatches(t, func() {
10638+
_ = validate.Var(6, "filepath")
10639+
}, "Bad field type int")
10640+
}
10641+
1057210642
func TestStartsWithValidation(t *testing.T) {
1057310643
tests := []struct {
1057410644
Value string `validate:"startswith=(/^ヮ^)/*:・゚✧"`
@@ -12361,10 +12431,12 @@ func TestPostCodeByIso3166Alpha2(t *testing.T) {
1236112431
{"00803", true},
1236212432
{"1234567", false},
1236312433
},
12364-
"LC": { // not support regexp for post code
12434+
"LC": {
12435+
// not support regexp for post code
1236512436
{"123456", false},
1236612437
},
12367-
"XX": { // not support country
12438+
"XX": {
12439+
// not support country
1236812440
{"123456", false},
1236912441
},
1237012442
}

0 commit comments

Comments
 (0)