diff --git a/pkg/datasizes/size.go b/pkg/datasizes/size.go new file mode 100644 index 0000000000..3394669f4b --- /dev/null +++ b/pkg/datasizes/size.go @@ -0,0 +1,72 @@ +package datasizes + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// Size is a wrapper around uint64 with support for reading from string +// yaml/toml, so {"size": 123}, {"size": "1234"}, {"size": "1 GiB"} are +// all supported +type Size uint64 + +// Uint64 returns the size as uint64. This is a convenience functions, +// it is strictly equivalent to uint64(Size(1)) +func (si Size) Uint64() uint64 { + return uint64(si) +} + +func (si *Size) UnmarshalTOML(data interface{}) error { + i, err := decodeSize(data) + if err != nil { + return fmt.Errorf("error decoding TOML size: %w", err) + } + *si = Size(i) + return nil +} + +func (si *Size) UnmarshalJSON(data []byte) error { + dec := json.NewDecoder(bytes.NewBuffer(data)) + dec.UseNumber() + + var v interface{} + if err := dec.Decode(&v); err != nil { + return err + } + i, err := decodeSize(v) + if err != nil { + // if only we could do better here and include e.g. the field + // name where this happend but encoding/json does not + // support this, c.f. https://github.com/golang/go/issues/58655 + return fmt.Errorf("error decoding size: %w", err) + } + *si = Size(i) + return nil +} + +// decodeSize takes an integer or string representing a data size (with a data +// suffix) and returns the uint64 representation. +func decodeSize(size any) (uint64, error) { + switch s := size.(type) { + case string: + return Parse(s) + case json.Number: + i, err := s.Int64() + if i < 0 { + return 0, fmt.Errorf("cannot be negative") + } + return uint64(i), err + case int64: + if s < 0 { + return 0, fmt.Errorf("cannot be negative") + } + return uint64(s), nil + case uint64: + return s, nil + case float64, float32: + return 0, fmt.Errorf("cannot be float") + default: + return 0, fmt.Errorf("failed to convert value \"%v\" to number", size) + } +} diff --git a/pkg/datasizes/size_test.go b/pkg/datasizes/size_test.go new file mode 100644 index 0000000000..30b08db50e --- /dev/null +++ b/pkg/datasizes/size_test.go @@ -0,0 +1,125 @@ +package datasizes_test + +import ( + "encoding/json" + "testing" + + "github.com/BurntSushi/toml" + "github.com/stretchr/testify/assert" + + "github.com/osbuild/images/pkg/datasizes" +) + +func TestSizeUnmarshalTOMLUnhappy(t *testing.T) { + cases := []struct { + name string + input string + err string + }{ + { + name: "wrong datatype/bool", + input: `size = true`, + err: `toml: line 1 (last key "size"): error decoding TOML size: failed to convert value "true" to number`, + }, + { + name: "wrong datatype/float", + input: `size = 3.14`, + err: `toml: line 1 (last key "size"): error decoding TOML size: cannot be float`, + }, + { + name: "wrong unit", + input: `size = "20 KG"`, + err: `toml: line 1 (last key "size"): error decoding TOML size: unknown data size units in string: 20 KG`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var v struct { + Size datasizes.Size `toml:"size"` + } + err := toml.Unmarshal([]byte(tc.input), &v) + assert.EqualError(t, err, tc.err, tc.input) + }) + } +} + +func TestSizeUnmarshalJSONUnhappy(t *testing.T) { + cases := []struct { + name string + input string + err string + }{ + { + name: "misize nor string nor int", + input: `{"size": true}`, + err: `error decoding size: failed to convert value "true" to number`, + }, + { + name: "wrong datatype/float", + input: `{"size": 3.14}`, + err: `error decoding size: strconv.ParseInt: parsing "3.14": invalid syntax`, + }, + { + name: "misize not parseable", + input: `{"size": "20 KG"}`, + err: `error decoding size: unknown data size units in string: 20 KG`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var v struct { + Size datasizes.Size `json:"size"` + } + err := json.Unmarshal([]byte(tc.input), &v) + assert.EqualError(t, err, tc.err, tc.input) + }) + } +} + +func TestSizeUnmarshalHappy(t *testing.T) { + cases := []struct { + name string + inputJSON string + inputTOML string + expected datasizes.Size + }{ + { + name: "int", + inputJSON: `{"size": 1234}`, + inputTOML: `size = 1234`, + expected: 1234, + }, + { + name: "str", + inputJSON: `{"size": "1234"}`, + inputTOML: `size = "1234"`, + expected: 1234, + }, + { + name: "str/with-unit", + inputJSON: `{"size": "1234 MiB"}`, + inputTOML: `size = "1234 MiB"`, + expected: 1234 * datasizes.MiB, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var v struct { + Size datasizes.Size `json:"size" toml:"size"` + } + err := toml.Unmarshal([]byte(tc.inputTOML), &v) + assert.NoError(t, err) + assert.Equal(t, tc.expected, v.Size, tc.inputTOML) + err = json.Unmarshal([]byte(tc.inputJSON), &v) + assert.NoError(t, err) + assert.Equal(t, tc.expected, v.Size, tc.inputJSON) + }) + } +} + +func TestSizeUint64(t *testing.T) { + assert.Equal(t, datasizes.Size(1234).Uint64(), uint64(1234)) +}