Repository: go-playground/validator (go)

Issue:
[Bug] `file` validator fails if string is a valid filepath but does not exist
- [X] I have looked at the documentation [here](https://pkg.go.dev/github.com/go-playground/validator/v10#section-documentation) first?
- [X] I have looked at the examples provided that may showcase my question [here](/_examples)?

### Package version eg. v9, v10: 
v10

### Issue, Question or Enhancement:
Because the isFile() uses `os.Stat`, it will completely fail if the filepath given does not (yet) exist on the system performing validation.

I have not tested, but I presume the `dir` validator suffers the same issue.

This can result in two very obvious failure conditions:

* If the system validating the data is not the system intending to *have* the file, it will fail.
* If the system validating the data is *going to* have a file created there but it does not yet exist, it will fail. (Think in terms of e.g. validating a configuration file's content, for instance.)


### Code sample, to showcase or reproduce:
Assuming you don't have file `/foobarbaz`:

```go
package main

import (
	"fmt"

	"github.com/go-playground/validator/v10"
)

type ExampleFile struct {
	Path string `validate:"required,file"`
}

var validate *validator.Validate

func main() {

	var ef = ExampleFile{Path:"/foobarbaz"}
	var err error

	validate = validator.New()

	err = validate.Struct(ef)

	fmt.Println(err)
}
```
Per the [POSIX spec](https://pubs.opengroup.org/onlinepubs/9699919799.2018edition/), there are some [useful hints](https://pubs.opengroup.org/onlinepubs/9699919799.2016edition/utilities/pathchk.html).

### Suggested Remediation

I'd like to propose instead using the regex pattern:

```
\([^\0 !$`&*()+]\|\\\(\ |\!|\$|\`|\&|\*|\(|\)|\+\)\)\+
```

(per [here](https://stackoverflow.com/a/10047501/733214)). It seems to most closely match the POSIX spec.

Windows may require a separate pattern:

```
(^([a-z]|[A-Z]):(?=\\(?![\0-\37<>:"/\\|?*])|\/(?![\0-\37<>:"/\\|?*])|$)|^\\(?=[\\\/][^\0-\37<>:"/\\|?*]+)|^(?=(\\|\/)$)|^\.(?=(\\|\/)$)|^\.\.(?=(\\|\/)$)|^(?=(\\|\/)[^\0-\37<>:"/\\|?*]+)|^\.(?=(\\|\/)[^\0-\37<>:"/\\|?*]+)|^\.\.(?=(\\|\/)[^\0-\37<>:"/\\|?*]+))((\\|\/)[^\0-\37<>:"/\\|?*]+|(\\|\/)$)*()$
```
(per [here](https://stackoverflow.com/a/42036026/733214)).

Obviously since they both include e.g. null byte characters, a string literal will not be usable so they'll probably need some escaping.

Pull request:
(New validator) Validate non-existing but valid file/directory paths
NOTE: Because these are added as new validators, `file` and `dir` validator behavior remains the same/backwards compatibility is maintained.

## Fixes Or Enhances
Fixes #808 

* Adds `filepath` and `dirpath` validators (for testing syntax validity of a path but not the required existence of it)
* Slight modifications to `file` and `dir` validator documentation to disambiguate from the above.
* Minor `gofmt`-ing (thanks, GoLand.)

**Make sure that you've checked the boxes below before you submit PR:**
- [X] Tests exist or have been written that cover this particular change.

@go-playground/validator-maintainers

Patch:
diff --git a/README.md b/README.md
index ef073fa..9c631d6 100644
--- a/README.md
+++ b/README.md
@@ -222,8 +222,10 @@ Baked-in Validations
 ### Other:
 | Tag | Description |
 | - | - |
-| dir | Directory |
-| file | File path |
+| dir | Existing Directory |
+| dirpath | Directory Path |
+| file | Existing File |
+| filepath | File Path |
 | isdefault | Is Default |
 | len | Length |
 | max | Maximum |
diff --git a/baked_in.go b/baked_in.go
index 122f30f..028bc84 100644
--- a/baked_in.go
+++ b/baked_in.go
@@ -7,6 +7,7 @@ import (
 	"encoding/hex"
 	"encoding/json"
 	"fmt"
+	"io/fs"
 	"net"
 	"net/url"
 	"os"
@@ -14,13 +15,14 @@ import (
 	"strconv"
 	"strings"
 	"sync"
+	"syscall"
 	"time"
 	"unicode/utf8"
 
 	"golang.org/x/crypto/sha3"
 	"golang.org/x/text/language"
 
-	urn "github.com/leodido/go-urn"
+	"github.com/leodido/go-urn"
 )
 
 // Func accepts a FieldLevel interface for all validation needs. The return
@@ -127,6 +129,7 @@ var (
 		"uri":                           isURI,
 		"urn_rfc2141":                   isUrnRFC2141, // RFC 2141
 		"file":                          isFile,
+		"filepath":                      isFilePath,
 		"base64":                        isBase64,
 		"base64url":                     isBase64URL,
 		"base64rawurl":                  isBase64RawURL,
@@ -199,6 +202,7 @@ var (
 		"html_encoded":                  isHTMLEncoded,
 		"url_encoded":                   isURLEncoded,
 		"dir":                           isDir,
+		"dirpath":                       isDirPath,
 		"json":                          isJSON,
 		"jwt":                           isJWT,
 		"hostname_port":                 isHostnamePort,
@@ -1464,7 +1468,7 @@ func isUrnRFC2141(fl FieldLevel) bool {
 	panic(fmt.Sprintf("Bad field type %T", field.Interface()))
 }
 
-// isFile is the validation function for validating if the current field's value is a valid file path.
+// isFile is the validation function for validating if the current field's value is a valid existing file path.
 func isFile(fl FieldLevel) bool {
 	field := fl.Field()
 
@@ -1481,6 +1485,57 @@ func isFile(fl FieldLevel) bool {
 	panic(fmt.Sprintf("Bad field type %T", field.Interface()))
 }
 
+// isFilePath is the validation function for validating if the current field's value is a valid file path.
+func isFilePath(fl FieldLevel) bool {
+
+	var exists bool
+	var err error
+
+	field := fl.Field()
+
+	// If it exists, it obviously is valid.
+	// This is done first to avoid code duplication and unnecessary additional logic.
+	if exists = isFile(fl); exists {
+		return true
+	}
+
+	// It does not exist but may still be a valid filepath.
+	switch field.Kind() {
+	case reflect.String:
+		// Every OS allows for whitespace, but none
+		// let you use a file with no filename (to my knowledge).
+		// Unless you're dealing with raw inodes, but I digress.
+		if strings.TrimSpace(field.String()) == "" {
+			return false
+		}
+		// We make sure it isn't a directory.
+		if strings.HasSuffix(field.String(), string(os.PathSeparator)) {
+			return false
+		}
+		if _, err = os.Stat(field.String()); err != nil {
+			switch t := err.(type) {
+			case *fs.PathError:
+				if t.Err == syscall.EINVAL {
+					// It's definitely an invalid character in the filepath.
+					return false
+				}
+				// It could be a permission error, a does-not-exist error, etc.
+				// Out-of-scope for this validation, though.
+				return true
+			default:
+				// Something went *seriously* wrong.
+				/*
+					Per https://pkg.go.dev/os#Stat:
+						"If there is an error, it will be of type *PathError."
+				*/
+				panic(err)
+			}
+		}
+	}
+
+	panic(fmt.Sprintf("Bad field type %T", field.Interface()))
+}
+
 // isE164 is the validation function for validating if the current field's value is a valid e.164 formatted phone number.
 func isE164(fl FieldLevel) bool {
 	return e164Regex.MatchString(fl.Field().String())
@@ -2354,7 +2409,7 @@ func isFQDN(fl FieldLevel) bool {
 	return fqdnRegexRFC1123.MatchString(val)
 }
 
-// isDir is the validation function for validating if the current field's value is a valid directory.
+// isDir is the validation function for validating if the current field's value is a valid existing directory.
 func isDir(fl FieldLevel) bool {
 	field := fl.Field()
 
@@ -2370,6 +2425,64 @@ func isDir(fl FieldLevel) bool {
 	panic(fmt.Sprintf("Bad field type %T", field.Interface()))
 }
 
+// isDirPath is the validation function for validating if the current field's value is a valid directory.
+func isDirPath(fl FieldLevel) bool {
+
+	var exists bool
+	var err error
+
+	field := fl.Field()
+
+	// If it exists, it obviously is valid.
+	// This is done first to avoid code duplication and unnecessary additional logic.
+	if exists = isDir(fl); exists {
+		return true
+	}
+
+	// It does not exist but may still be a valid path.
+	switch field.Kind() {
+	case reflect.String:
+		// Every OS allows for whitespace, but none
+		// let you use a dir with no name (to my knowledge).
+		// Unless you're dealing with raw inodes, but I digress.
+		if strings.TrimSpace(field.String()) == "" {
+			return false
+		}
+		if _, err = os.Stat(field.String()); err != nil {
+			switch t := err.(type) {
+			case *fs.PathError:
+				if t.Err == syscall.EINVAL {
+					// It's definitely an invalid character in the path.
+					return false
+				}
+				// It could be a permission error, a does-not-exist error, etc.
+				// Out-of-scope for this validation, though.
+				// Lastly, we make sure it is a directory.
+				if strings.HasSuffix(field.String(), string(os.PathSeparator)) {
+					return true
+				} else {
+					return false
+				}
+			default:
+				// Something went *seriously* wrong.
+				/*
+					Per https://pkg.go.dev/os#Stat:
+						"If there is an error, it will be of type *PathError."
+				*/
+				panic(err)
+			}
+		}
+		// We repeat the check here to make sure it is an explicit directory in case the above os.Stat didn't trigger an error.
+		if strings.HasSuffix(field.String(), string(os.PathSeparator)) {
+			return true
+		} else {
+			return false
+		}
+	}
+
+	panic(fmt.Sprintf("Bad field type %T", field.Interface()))
+}
+
 // isJSON is the validation function for validating if the current field's value is a valid json string.
 func isJSON(fl FieldLevel) bool {
 	field := fl.Field()
diff --git a/doc.go b/doc.go
index e0b79b7..bffabc4 100644
--- a/doc.go
+++ b/doc.go
@@ -863,7 +863,8 @@ This validates that a string value is a valid JWT
 
 	Usage: jwt
 
-# File path
+
+# File
 
 This validates that a string value contains a valid file path and that
 the file exists on the machine.
@@ -871,6 +872,16 @@ This is done using os.Stat, which is a platform independent function.
 
 	Usage: file
 
+
+# File Path
+
+This validates that a string value contains a valid file path but does not
+validate the existence of that file.
+This is done using os.Stat, which is a platform independent function.
+
+	Usage: filepath
+
+
 # URL String
 
 This validates that a string value contains a valid url
@@ -912,6 +923,7 @@ you can use this with the omitempty tag.
 
 	Usage: base64url
 
+
 # Base64RawURL String
 
 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.
 
 	Usage: base64url
 
+
 # Bitcoin Address
 
 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.
 
 	Usage: dir
 
+
+# Directory Path
+
+This validates that a string value contains a valid directory but does
+not validate the existence of that directory.
+This is done using os.Stat, which is a platform independent function.
+It is safest to suffix the string with os.PathSeparator if the directory
+may not exist at the time of validation.
+
+	Usage: dirpath
+
+
 # HostPort
 
 This validates that a string value contains a valid DNS hostname and port that


Test patch:
diff --git a/validator_test.go b/validator_test.go
index 8e1ea3e..a4beb9f 100644
--- a/validator_test.go
+++ b/validator_test.go
@@ -8,6 +8,7 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
+	"os"
 	"path/filepath"
 	"reflect"
 	"strings"
@@ -3819,12 +3820,14 @@ func TestDataURIValidation(t *testing.T) {
 		{"data:image/png;base64,TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4=", true},
 		{"data:text/plain;base64,Vml2YW11cyBmZXJtZW50dW0gc2VtcGVyIHBvcnRhLg==", true},
 		{"image/gif;base64,U3VzcGVuZGlzc2UgbGVjdHVzIGxlbw==", false},
-		{"data:image/gif;base64,MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMPNS1Ufof9EW/M98FNw" +
-			"UAKrwflsqVxaxQjBQnHQmiI7Vac40t8x7pIb8gLGV6wL7sBTJiPovJ0V7y7oc0Ye" +
-			"rhKh0Rm4skP2z/jHwwZICgGzBvA0rH8xlhUiTvcwDCJ0kc+fh35hNt8srZQM4619" +
-			"FTgB66Xmp4EtVyhpQV+t02g6NzK72oZI0vnAvqhpkxLeLiMCyrI416wHm5Tkukhx" +
-			"QmcL2a6hNOyu0ixX/x2kSFXApEnVrJ+/IxGyfyw8kf4N2IZpW5nEP847lpfj0SZZ" +
-			"Fwrd1mnfnDbYohX2zRptLy2ZUn06Qo9pkG5ntvFEPo9bfZeULtjYzIl6K8gJ2uGZ" + "HQIDAQAB", true},
+		{
+			"data:image/gif;base64,MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMPNS1Ufof9EW/M98FNw" +
+				"UAKrwflsqVxaxQjBQnHQmiI7Vac40t8x7pIb8gLGV6wL7sBTJiPovJ0V7y7oc0Ye" +
+				"rhKh0Rm4skP2z/jHwwZICgGzBvA0rH8xlhUiTvcwDCJ0kc+fh35hNt8srZQM4619" +
+				"FTgB66Xmp4EtVyhpQV+t02g6NzK72oZI0vnAvqhpkxLeLiMCyrI416wHm5Tkukhx" +
+				"QmcL2a6hNOyu0ixX/x2kSFXApEnVrJ+/IxGyfyw8kf4N2IZpW5nEP847lpfj0SZZ" +
+				"Fwrd1mnfnDbYohX2zRptLy2ZUn06Qo9pkG5ntvFEPo9bfZeULtjYzIl6K8gJ2uGZ" + "HQIDAQAB", true,
+		},
 		{"data:image/png;base64,12345", false},
 		{"", false},
 		{"data:text,:;base85,U3VzcGVuZGlzc2UgbGVjdHVzIGxlbw==", false},
@@ -5732,6 +5735,39 @@ func TestFileValidation(t *testing.T) {
 	}, "Bad field type int")
 }
 
+func TestFilePathValidation(t *testing.T) {
+	validate := New()
+
+	tests := []struct {
+		title    string
+		param    string
+		expected bool
+	}{
+		{"empty filepath", "", false},
+		{"valid filepath", filepath.Join("testdata", "a.go"), true},
+		{"invalid filepath", filepath.Join("testdata", "no\000.go"), false},
+		{"directory, not a filepath", "testdata" + string(os.PathSeparator), false},
+	}
+
+	for _, test := range tests {
+		errs := validate.Var(test.param, "filepath")
+
+		if test.expected {
+			if !IsEqual(errs, nil) {
+				t.Fatalf("Test: '%s' failed Error: %s", test.title, errs)
+			}
+		} else {
+			if IsEqual(errs, nil) {
+				t.Fatalf("Test: '%s' failed Error: %s", test.title, errs)
+			}
+		}
+	}
+
+	PanicMatches(t, func() {
+		_ = validate.Var(6, "filepath")
+	}, "Bad field type int")
+}
+
 func TestEthereumAddressValidation(t *testing.T) {
 	validate := New()
 
@@ -10569,6 +10605,40 @@ func TestDirValidation(t *testing.T) {
 	}, "Bad field type int")
 }
 
+func TestDirPathValidation(t *testing.T) {
+	validate := New()
+
+	tests := []struct {
+		title    string
+		param    string
+		expected bool
+	}{
+		{"empty dirpath", "", false},
+		{"valid dirpath - exists", "testdata", true},
+		{"valid dirpath - explicit", "testdatanoexist" + string(os.PathSeparator), true},
+		{"invalid dirpath", "testdata\000" + string(os.PathSeparator), false},
+		{"file, not a dirpath", filepath.Join("testdata", "a.go"), false},
+	}
+
+	for _, test := range tests {
+		errs := validate.Var(test.param, "dirpath")
+
+		if test.expected {
+			if !IsEqual(errs, nil) {
+				t.Fatalf("Test: '%s' failed Error: %s", test.title, errs)
+			}
+		} else {
+			if IsEqual(errs, nil) {
+				t.Fatalf("Test: '%s' failed Error: %s", test.title, errs)
+			}
+		}
+	}
+
+	PanicMatches(t, func() {
+		_ = validate.Var(6, "filepath")
+	}, "Bad field type int")
+}
+
 func TestStartsWithValidation(t *testing.T) {
 	tests := []struct {
 		Value       string `validate:"startswith=(/^ヮ^)/*:・ﾟ✧"`
@@ -12361,10 +12431,12 @@ func TestPostCodeByIso3166Alpha2(t *testing.T) {
 			{"00803", true},
 			{"1234567", false},
 		},
-		"LC": { // not support regexp for post code
+		"LC": {
+			// not support regexp for post code
 			{"123456", false},
 		},
-		"XX": { // not support country
+		"XX": {
+			// not support country
 			{"123456", false},
 		},
 	}