diff --git a/hashstructure.go b/hashstructure.go index 3dc0eb7..88a410e 100644 --- a/hashstructure.go +++ b/hashstructure.go @@ -1,6 +1,7 @@ package hashstructure import ( + "encoding" "encoding/binary" "fmt" "hash" @@ -37,6 +38,11 @@ type HashOptions struct { // precedence (meaning that if the type doesn't implement fmt.Stringer, we // panic) UseStringer bool + + // UnhashedStructFallback will attempt to make use of the BinaryEncoder and + // Stringer interfaces (in that order) to hash structs that contain no + // exported fields. + UnhashedStructFallback bool } // Format specifies the hashing process used. Different formats typically @@ -72,29 +78,28 @@ const ( // // Notes on the value: // -// * Unexported fields on structs are ignored and do not affect the +// - Unexported fields on structs are ignored and do not affect the // hash value. // -// * Adding an exported field to a struct with the zero value will change +// - Adding an exported field to a struct with the zero value will change // the hash value. // // For structs, the hashing can be controlled using tags. For example: // -// struct { -// Name string -// UUID string `hash:"ignore"` -// } +// struct { +// Name string +// UUID string `hash:"ignore"` +// } // // The available tag values are: // -// * "ignore" or "-" - The field will be ignored and not affect the hash code. -// -// * "set" - The field will be treated as a set, where ordering doesn't -// affect the hash code. This only works for slices. +// - "ignore" or "-" - The field will be ignored and not affect the hash code. // -// * "string" - The field will be hashed as a string, only works when the -// field implements fmt.Stringer +// - "set" - The field will be treated as a set, where ordering doesn't +// affect the hash code. This only works for slices. // +// - "string" - The field will be hashed as a string, only works when the +// field implements fmt.Stringer func Hash(v interface{}, format Format, opts *HashOptions) (uint64, error) { // Validate our format if format <= formatInvalid || format >= formatMax { @@ -117,25 +122,27 @@ func Hash(v interface{}, format Format, opts *HashOptions) (uint64, error) { // Create our walker and walk the structure w := &walker{ - format: format, - h: opts.Hasher, - tag: opts.TagName, - zeronil: opts.ZeroNil, - ignorezerovalue: opts.IgnoreZeroValue, - sets: opts.SlicesAsSets, - stringer: opts.UseStringer, + format: format, + h: opts.Hasher, + tag: opts.TagName, + zeronil: opts.ZeroNil, + ignorezerovalue: opts.IgnoreZeroValue, + sets: opts.SlicesAsSets, + stringer: opts.UseStringer, + unhashedstructfallback: opts.UnhashedStructFallback, } return w.visit(reflect.ValueOf(v), nil) } type walker struct { - format Format - h hash.Hash64 - tag string - zeronil bool - ignorezerovalue bool - sets bool - stringer bool + format Format + h hash.Hash64 + tag string + zeronil bool + ignorezerovalue bool + sets bool + stringer bool + unhashedstructfallback bool } type visitOpts struct { @@ -307,23 +314,27 @@ func (w *walker) visit(v reflect.Value, opts *visitOpts) (uint64, error) { } l := v.NumField() + unhashedfields := 0 for i := 0; i < l; i++ { if innerV := v.Field(i); v.CanSet() || t.Field(i).Name != "_" { var f visitFlag fieldType := t.Field(i) if fieldType.PkgPath != "" { + unhashedfields++ // Unexported continue } tag := fieldType.Tag.Get(w.tag) if tag == "ignore" || tag == "-" { + unhashedfields++ // Ignore this field continue } if w.ignorezerovalue { if innerV.IsZero() { + unhashedfields++ continue } } @@ -348,6 +359,7 @@ func (w *walker) visit(v reflect.Value, opts *visitOpts) (uint64, error) { return 0, err } if !incl { + unhashedfields++ continue } } @@ -380,6 +392,27 @@ func (w *walker) visit(v reflect.Value, opts *visitOpts) (uint64, error) { h = hashFinishUnordered(w.h, h) } } + // no fields involved in the hash! try binary and string instead. + if unhashedfields == l && w.unhashedstructfallback { + var data []byte + if impl, ok := parent.(encoding.BinaryMarshaler); ok { + data, err = impl.MarshalBinary() + if err != nil { + return 0, err + } + } + + if impl, ok := parent.(fmt.Stringer); ok { + data = []byte(impl.String()) + } + + w.h.Reset() + _, err := w.h.Write(data) + if err != nil { + return 0, err + } + return w.h.Sum64(), nil + } return h, nil @@ -453,11 +486,11 @@ func hashUpdateUnordered(a, b uint64) uint64 { // hashUpdateUnordered can effectively cancel out a previous change to the hash // result if the same hash value appears later on. For example, consider: // -// hashUpdateUnordered(hashUpdateUnordered("A", "B"), hashUpdateUnordered("A", "C")) = -// H("A") ^ H("B")) ^ (H("A") ^ H("C")) = -// (H("A") ^ H("A")) ^ (H("B") ^ H(C)) = -// H(B) ^ H(C) = -// hashUpdateUnordered(hashUpdateUnordered("Z", "B"), hashUpdateUnordered("Z", "C")) +// hashUpdateUnordered(hashUpdateUnordered("A", "B"), hashUpdateUnordered("A", "C")) = +// H("A") ^ H("B")) ^ (H("A") ^ H("C")) = +// (H("A") ^ H("A")) ^ (H("B") ^ H(C)) = +// H(B) ^ H(C) = +// hashUpdateUnordered(hashUpdateUnordered("Z", "B"), hashUpdateUnordered("Z", "C")) // // hashFinishUnordered "hardens" the result, so that encountering partially // overlapping input data later on in a different context won't cancel out. diff --git a/hashstructure_test.go b/hashstructure_test.go index 7b0034a..2da2358 100644 --- a/hashstructure_test.go +++ b/hashstructure_test.go @@ -729,3 +729,135 @@ func (t *testHashablePointer) Hash() (uint64, error) { return 100, nil } + +type UnexportedStringer struct { + n int +} + +func (u UnexportedStringer) String() string { + return fmt.Sprintf("%d", u.n) +} + +type UnexportedBinaryer struct { + n int +} + +func (u UnexportedBinaryer) MarshalBinary() (data []byte, err error) { + return []byte(fmt.Sprintf("%d", u.n)), nil +} + +func TestHash_StringIgnoredStructs(t *testing.T) { + cases := []struct { + One, Two interface{} + Match bool + Err string + }{ + { + UnexportedStringer{n: 1}, + UnexportedStringer{n: 1}, + true, + "", + }, + { + UnexportedStringer{n: 1}, + UnexportedStringer{n: 2}, + false, + "", + }, + { + []interface{}{UnexportedStringer{n: 1}}, + []interface{}{UnexportedStringer{n: 1}}, + true, + "", + }, + { + []interface{}{UnexportedStringer{n: 1}}, + []interface{}{UnexportedStringer{n: 2}}, + false, + "", + }, + { + map[string]interface{}{"v": UnexportedStringer{n: 1}}, + map[string]interface{}{"v": UnexportedStringer{n: 1}}, + true, + "", + }, + { + map[string]interface{}{"v": UnexportedStringer{n: 1}}, + map[string]interface{}{"v": UnexportedStringer{n: 2}}, + false, + "", + }, + { + UnexportedBinaryer{n: 1}, + UnexportedBinaryer{n: 1}, + true, + "", + }, + { + UnexportedBinaryer{n: 1}, + UnexportedBinaryer{n: 2}, + false, + "", + }, + { + []interface{}{UnexportedBinaryer{n: 1}}, + []interface{}{UnexportedBinaryer{n: 1}}, + true, + "", + }, + { + []interface{}{UnexportedBinaryer{n: 1}}, + []interface{}{UnexportedBinaryer{n: 2}}, + false, + "", + }, + { + map[string]interface{}{"v": UnexportedBinaryer{n: 1}}, + map[string]interface{}{"v": UnexportedBinaryer{n: 1}}, + true, + "", + }, + { + map[string]interface{}{"v": UnexportedBinaryer{n: 1}}, + map[string]interface{}{"v": UnexportedBinaryer{n: 2}}, + false, + "", + }, + } + + for i, tc := range cases { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + one, err := Hash(tc.One, testFormat, &HashOptions{UnhashedStructFallback: true}) + if tc.Err != "" { + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), tc.Err) { + t.Fatalf("expected error to contain %q, got: %s", tc.Err, err) + } + + return + } + if err != nil { + t.Fatalf("Failed to hash %#v: %s", tc.One, err) + } + + two, err := Hash(tc.Two, testFormat, &HashOptions{UnhashedStructFallback: true}) + if err != nil { + t.Fatalf("Failed to hash %#v: %s", tc.Two, err) + } + + // Zero is always wrong + if one == 0 { + t.Fatalf("zero hash: %#v", tc.One) + } + + // Compare + if (one == two) != tc.Match { + t.Fatalf("bad, expected: %#v\n\n%#v\n\n%#v", tc.Match, tc.One, tc.Two) + } + }) + } +}