Repository: go-playground/validator
Pull request #1171 (2023-09-20 01:36:26)

fix file URL validator
## Fixes Or Enhances

This PR adds an additional check for the file URL schema.
Connected to #1165

**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

coveralls: 
[![Coverage Status](https://coveralls.io/builds/62768920/badge)](https://coveralls.io/builds/62768920)

coverage: 73.864% (+0.02%) from 73.849% when pulling **ed3b91176879a42dfd92760fab296c8e5190bb22 on nodivbyzero:file-url-validator-fix** into **8d50f2fd8c2f02fbdf1d44d66171b955c2f4eb50 on go-playground:master**.


Patch:
diff --git a/_examples/simple/main.go b/_examples/simple/main.go
index 35a56c1..6581412 100644
--- a/_examples/simple/main.go
+++ b/_examples/simple/main.go
@@ -12,7 +12,7 @@ type User struct {
 	LastName       string     `validate:"required"`
 	Age            uint8      `validate:"gte=0,lte=130"`
 	Email          string     `validate:"required,email"`
-	Gender         string     `validate:"oneof=male female prefer_not_to`
+	Gender         string     `validate:"oneof=male female prefer_not_to"`
 	FavouriteColor string     `validate:"iscolor"`                // alias for 'hexcolor|rgb|rgba|hsl|hsla'
 	Addresses      []*Address `validate:"required,dive,required"` // a person can have a home and cottage...
 }
diff --git a/baked_in.go b/baked_in.go
index cc92b78..7d993aa 100644
--- a/baked_in.go
+++ b/baked_in.go
@@ -51,6 +51,7 @@ var (
 		endKeysTag:        {},
 		structOnlyTag:     {},
 		omitempty:         {},
+		omitnil:           {},
 		skipValidationTag: {},
 		utf8HexComma:      {},
 		utf8Pipe:          {},
@@ -373,9 +374,9 @@ func isMAC(fl FieldLevel) bool {
 
 // isCIDRv4 is the validation function for validating if the field's value is a valid v4 CIDR address.
 func isCIDRv4(fl FieldLevel) bool {
-	ip, _, err := net.ParseCIDR(fl.Field().String())
+	ip, net, err := net.ParseCIDR(fl.Field().String())
 
-	return err == nil && ip.To4() != nil
+	return err == nil && ip.To4() != nil && net.IP.Equal(ip)
 }
 
 // isCIDRv6 is the validation function for validating if the field's value is a valid v6 CIDR address.
@@ -508,47 +509,47 @@ func isASCII(fl FieldLevel) bool {
 
 // isUUID5 is the validation function for validating if the field's value is a valid v5 UUID.
 func isUUID5(fl FieldLevel) bool {
-	return uUID5Regex.MatchString(fl.Field().String())
+	return fieldMatchesRegexByStringerValOrString(uUID5Regex, fl)
 }
 
 // isUUID4 is the validation function for validating if the field's value is a valid v4 UUID.
 func isUUID4(fl FieldLevel) bool {
-	return uUID4Regex.MatchString(fl.Field().String())
+	return fieldMatchesRegexByStringerValOrString(uUID4Regex, fl)
 }
 
 // isUUID3 is the validation function for validating if the field's value is a valid v3 UUID.
 func isUUID3(fl FieldLevel) bool {
-	return uUID3Regex.MatchString(fl.Field().String())
+	return fieldMatchesRegexByStringerValOrString(uUID3Regex, fl)
 }
 
 // isUUID is the validation function for validating if the field's value is a valid UUID of any version.
 func isUUID(fl FieldLevel) bool {
-	return uUIDRegex.MatchString(fl.Field().String())
+	return fieldMatchesRegexByStringerValOrString(uUIDRegex, fl)
 }
 
 // isUUID5RFC4122 is the validation function for validating if the field's value is a valid RFC4122 v5 UUID.
 func isUUID5RFC4122(fl FieldLevel) bool {
-	return uUID5RFC4122Regex.MatchString(fl.Field().String())
+	return fieldMatchesRegexByStringerValOrString(uUID5RFC4122Regex, fl)
 }
 
 // isUUID4RFC4122 is the validation function for validating if the field's value is a valid RFC4122 v4 UUID.
 func isUUID4RFC4122(fl FieldLevel) bool {
-	return uUID4RFC4122Regex.MatchString(fl.Field().String())
+	return fieldMatchesRegexByStringerValOrString(uUID4RFC4122Regex, fl)
 }
 
 // isUUID3RFC4122 is the validation function for validating if the field's value is a valid RFC4122 v3 UUID.
 func isUUID3RFC4122(fl FieldLevel) bool {
-	return uUID3RFC4122Regex.MatchString(fl.Field().String())
+	return fieldMatchesRegexByStringerValOrString(uUID3RFC4122Regex, fl)
 }
 
 // isUUIDRFC4122 is the validation function for validating if the field's value is a valid RFC4122 UUID of any version.
 func isUUIDRFC4122(fl FieldLevel) bool {
-	return uUIDRFC4122Regex.MatchString(fl.Field().String())
+	return fieldMatchesRegexByStringerValOrString(uUIDRFC4122Regex, fl)
 }
 
 // isULID is the validation function for validating if the field's value is a valid ULID.
 func isULID(fl FieldLevel) bool {
-	return uLIDRegex.MatchString(fl.Field().String())
+	return fieldMatchesRegexByStringerValOrString(uLIDRegex, fl)
 }
 
 // isMD4 is the validation function for validating if the field's value is a valid MD4.
@@ -1413,6 +1414,15 @@ func isURI(fl FieldLevel) bool {
 	panic(fmt.Sprintf("Bad field type %T", field.Interface()))
 }
 
+// isFileURL is the helper function for validating if the `path` valid file URL as per RFC8089
+func isFileURL(path string) bool {
+	if !strings.HasPrefix(path, "file:/") {
+		return false
+	}
+	_, err := url.ParseRequestURI(path)
+	return err == nil
+}
+
 // isURL is the validation function for validating if the current field's value is a valid URL.
 func isURL(fl FieldLevel) bool {
 	field := fl.Field()
@@ -1420,12 +1430,16 @@ func isURL(fl FieldLevel) bool {
 	switch field.Kind() {
 	case reflect.String:
 
-		s := field.String()
+		s := strings.ToLower(field.String())
 
 		if len(s) == 0 {
 			return false
 		}
 
+		if isFileURL(s) {
+			return true
+		}
+
 		url, err := url.Parse(s)
 		if err != nil || url.Scheme == "" {
 			return false
diff --git a/cache.go b/cache.go
index bbfd2a4..0f4fa6b 100644
--- a/cache.go
+++ b/cache.go
@@ -20,6 +20,7 @@ const (
 	typeOr
 	typeKeys
 	typeEndKeys
+	typeOmitNil
 )
 
 const (
@@ -252,6 +253,10 @@ func (v *Validate) parseFieldTagsRecursive(tag string, fieldName string, alias s
 			current.typeof = typeOmitEmpty
 			continue
 
+		case omitnil:
+			current.typeof = typeOmitNil
+			continue
+
 		case structOnlyTag:
 			current.typeof = typeStructOnly
 			continue
diff --git a/doc.go b/doc.go
index c4dbb59..b474091 100644
--- a/doc.go
+++ b/doc.go
@@ -194,6 +194,13 @@ such as min or max won't run, but if a value is set validation will run.
 
 	Usage: omitempty
 
+# Omit Nil
+
+Allows to skip the validation if the value is nil (same as omitempty, but
+only for the nil-values).
+
+	Usage: omitnil
+
 # Dive
 
 This tells the validator to dive into a slice, array or map and validate that
diff --git a/util.go b/util.go
index 4bd947b..1685159 100644
--- a/util.go
+++ b/util.go
@@ -1,7 +1,9 @@
 package validator
 
 import (
+	"fmt"
 	"reflect"
+	"regexp"
 	"strconv"
 	"strings"
 	"time"
@@ -292,3 +294,18 @@ func panicIf(err error) {
 		panic(err.Error())
 	}
 }
+
+// Checks if field value matches regex. If fl.Field can be cast to Stringer, it uses the Stringer interfaces
+// String() return value. Otherwise, it uses fl.Field's String() value.
+func fieldMatchesRegexByStringerValOrString(regex *regexp.Regexp, fl FieldLevel) bool {
+	switch fl.Field().Kind() {
+	case reflect.String:
+		return regex.MatchString(fl.Field().String())
+	default:
+		if stringer, ok := fl.Field().Interface().(fmt.Stringer); ok {
+			return regex.MatchString(stringer.String())
+		} else {
+			return regex.MatchString(fl.Field().String())
+		}
+	}
+}
diff --git a/validator.go b/validator.go
index 342c4ec..a072d39 100644
--- a/validator.go
+++ b/validator.go
@@ -112,6 +112,10 @@ func (v *validate) traverseField(ctx context.Context, parent reflect.Value, curr
 			return
 		}
 
+		if ct.typeof == typeOmitNil && (kind != reflect.Invalid && current.IsNil()) {
+			return
+		}
+
 		if ct.hasTag {
 			if kind == reflect.Invalid {
 				v.str1 = string(append(ns, cf.altName...))
@@ -233,6 +237,26 @@ OUTER:
 			ct = ct.next
 			continue
 
+		case typeOmitNil:
+			v.slflParent = parent
+			v.flField = current
+			v.cf = cf
+			v.ct = ct
+
+			switch field := v.Field(); field.Kind() {
+			case reflect.Slice, reflect.Map, reflect.Ptr, reflect.Interface, reflect.Chan, reflect.Func:
+				if field.IsNil() {
+					return
+				}
+			default:
+				if v.fldIsPointer && field.Interface() == nil {
+					return
+				}
+			}
+
+			ct = ct.next
+			continue
+
 		case typeEndKeys:
 			return
 
diff --git a/validator_instance.go b/validator_instance.go
index a4dbdd0..d5a7be1 100644
--- a/validator_instance.go
+++ b/validator_instance.go
@@ -22,6 +22,7 @@ const (
 	structOnlyTag         = "structonly"
 	noStructLevelTag      = "nostructlevel"
 	omitempty             = "omitempty"
+	omitnil               = "omitnil"
 	isdefault             = "isdefault"
 	requiredWithoutAllTag = "required_without_all"
 	requiredWithoutTag    = "required_without"


Test patch:
diff --git a/validator_test.go b/validator_test.go
index 09ff1df..4a08d01 100644
--- a/validator_test.go
+++ b/validator_test.go
@@ -2575,19 +2575,26 @@ func TestCIDRv4Validation(t *testing.T) {
 		param    string
 		expected bool
 	}{
-		{"10.0.0.0/0", true},
-		{"10.0.0.1/8", true},
-		{"172.16.0.1/16", true},
-		{"192.168.0.1/24", true},
-		{"192.168.255.254/24", true},
+		{"0.0.0.0/0", true},
+		{"10.0.0.0/0", false},
+		{"10.0.0.0/8", true},
+		{"10.0.0.1/8", false},
+		{"172.16.0.0/16", true},
+		{"172.16.0.1/16", false},
+		{"192.168.0.0/24", true},
+		{"192.168.0.1/24", false},
+		{"192.168.255.0/24", true},
+		{"192.168.255.254/24", false},
 		{"192.168.255.254/48", false},
 		{"192.168.255.256/24", false},
-		{"172.16.255.254/16", true},
+		{"172.16.0.0/16", true},
+		{"172.16.255.254/16", false},
 		{"172.16.256.255/16", false},
 		{"2001:cdba:0000:0000:0000:0000:3257:9652/64", false},
 		{"2001:cdba:0000:0000:0000:0000:3257:9652/256", false},
 		{"2001:cdba:0:0:0:0:3257:9652/32", false},
 		{"2001:cdba::3257:9652/16", false},
+		{"172.56.1.0/16", false},
 	}
 
 	validate := New()
@@ -4098,6 +4105,23 @@ func TestUUID3Validation(t *testing.T) {
 	}
 }
 
+type uuidTestType struct {
+	val string
+}
+
+func (u uuidTestType) String() string {
+	return u.val
+}
+
+type uuidAlias string
+
+func (u uuidAlias) String() string {
+	return "This is a UUID " + string(u)
+}
+
+var _ fmt.Stringer = uuidTestType{}
+var _ fmt.Stringer = uuidAlias("")
+
 func TestUUIDValidation(t *testing.T) {
 	tests := []struct {
 		param    string
@@ -4134,6 +4158,31 @@ func TestUUIDValidation(t *testing.T) {
 			}
 		}
 	}
+
+	// Test UUID validation on uuid structs type that implements Stringer interface.
+	structWithValidUUID := struct {
+		UUID uuidTestType `validate:"uuid"`
+	}{
+		UUID: uuidTestType{val: "a987fbc9-4bed-3078-cf07-9141ba07c9f3"},
+	}
+	structWithInvalidUUID := struct {
+		UUID uuidTestType `validate:"uuid"`
+	}{
+		UUID: uuidTestType{val: "934859"},
+	}
+
+	if err := validate.Struct(structWithValidUUID); err != nil {
+		t.Fatalf("UUID failed Error: %s", err)
+	}
+	if err := validate.Struct(structWithInvalidUUID); err == nil {
+		t.Fatal("UUID failed Error expected but received nil")
+	}
+
+	// Test on Alias type with Stringer interface.
+	alias := uuidAlias("a987fbc9-4bed-3078-cf07-9141ba07c9f3")
+	if err := validate.Var(alias, "uuid"); err != nil {
+		t.Fatalf("UUID failed Error: %s", err)
+	}
 }
 
 func TestUUID5RFC4122Validation(t *testing.T) {
@@ -8090,6 +8139,12 @@ func TestUrl(t *testing.T) {
 		{"./rel/test/dir", false},
 		{"irc:", false},
 		{"http://", false},
+		{"file://path/to/file.txt", true},
+		{"file:///c:/Windows/file.txt", true},
+		{"file://localhost/path/to/file.txt", true},
+		{"file://localhost/c:/WINDOWS/file.txt", true},
+		{"file://", true},
+		{"file:////remotehost/path/file.txt", true},
 	}
 
 	validate := New()
@@ -13147,14 +13202,14 @@ func TestSpiceDBValueFormatValidation(t *testing.T) {
 		tag      string
 		expected bool
 	}{
-		//Must be an asterisk OR a string containing alphanumeric characters and a restricted set a special symbols: _ | / - = +
+		// Must be an asterisk OR a string containing alphanumeric characters and a restricted set a special symbols: _ | / - = +
 		{"*", "spicedb=id", true},
 		{`azAZ09_|/-=+`, "spicedb=id", true},
 		{`a*`, "spicedb=id", false},
 		{`/`, "spicedb=id", true},
 		{"*", "spicedb", true},
 
-		//Must begin and end with a lowercase letter, may also contain numbers and underscores between, min length 3, max length 64
+		// Must begin and end with a lowercase letter, may also contain numbers and underscores between, min length 3, max length 64
 		{"a", "spicedb=permission", false},
 		{"1", "spicedb=permission", false},
 		{"a1", "spicedb=permission", false},
@@ -13164,7 +13219,7 @@ func TestSpiceDBValueFormatValidation(t *testing.T) {
 		{"abcdefghijklmnopqrstuvwxyz_0123456789_abcdefghijklmnopqrstuvwxyz", "spicedb=permission", true},
 		{"abcdefghijklmnopqrstuvwxyz_01234_56789_abcdefghijklmnopqrstuvwxyz", "spicedb=permission", false},
 
-		//Object types follow the same rules as permissions for the type name plus an optional prefix up to 63 characters with a /
+		// Object types follow the same rules as permissions for the type name plus an optional prefix up to 63 characters with a /
 		{"a", "spicedb=type", false},
 		{"1", "spicedb=type", false},
 		{"a1", "spicedb=type", false},
@@ -13557,3 +13612,47 @@ func TestTimeRequired(t *testing.T) {
 	NotEqual(t, err, nil)
 	AssertError(t, err.(ValidationErrors), "TestTime.Time", "TestTime.Time", "Time", "Time", "required")
 }
+
+func TestOmitNilAndRequired(t *testing.T) {
+	type (
+		OmitEmpty struct {
+			Str    string  `validate:"omitempty,required,min=10"`
+			StrPtr *string `validate:"omitempty,required,min=10"`
+			Inner  *OmitEmpty
+		}
+		OmitNil struct {
+			Str    string  `validate:"omitnil,required,min=10"`
+			StrPtr *string `validate:"omitnil,required,min=10"`
+			Inner  *OmitNil
+		}
+	)
+
+	var (
+		validate = New(WithRequiredStructEnabled())
+		valid    = "this is the long string to pass the validation rule"
+	)
+
+	t.Run("compare using valid data", func(t *testing.T) {
+		err1 := validate.Struct(OmitEmpty{Str: valid, StrPtr: &valid, Inner: &OmitEmpty{Str: valid, StrPtr: &valid}})
+		err2 := validate.Struct(OmitNil{Str: valid, StrPtr: &valid, Inner: &OmitNil{Str: valid, StrPtr: &valid}})
+
+		Equal(t, err1, nil)
+		Equal(t, err2, nil)
+	})
+
+	t.Run("compare fully empty omitempty and omitnil", func(t *testing.T) {
+		err1 := validate.Struct(OmitEmpty{})
+		err2 := validate.Struct(OmitNil{})
+
+		Equal(t, err1, nil)
+		AssertError(t, err2, "OmitNil.Str", "OmitNil.Str", "Str", "Str", "required")
+	})
+
+	t.Run("validate in deep", func(t *testing.T) {
+		err1 := validate.Struct(OmitEmpty{Str: valid, Inner: &OmitEmpty{}})
+		err2 := validate.Struct(OmitNil{Str: valid, Inner: &OmitNil{}})
+
+		Equal(t, err1, nil)
+		AssertError(t, err2, "OmitNil.Inner.Str", "OmitNil.Inner.Str", "Str", "Str", "required")
+	})
+}