Skip to content

Commit

Permalink
Merge pull request #539 from smallstep/mariano/timeFunctions
Browse files Browse the repository at this point in the history
Improve time functions
  • Loading branch information
maraino committed Jul 3, 2024
2 parents 1225540 + 329b3f1 commit 446f4e2
Show file tree
Hide file tree
Showing 3 changed files with 293 additions and 18 deletions.
116 changes: 109 additions & 7 deletions internal/templates/funcmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package templates

import (
"errors"
"strings"
"text/template"
"time"

Expand All @@ -10,12 +11,33 @@ import (
)

// GetFuncMap returns the list of functions provided by sprig. It adds the
// function "toTime" and changes the function "fail".
// functions "toTime", "formatTime", "parseTime", "mustParseTime",
// "toTimeLayout" and changes the function "fail".
//
// The "toTime" function receives a time or a Unix epoch and formats it to
// RFC3339 in UTC. The "fail" function sets the provided message, so that
// template errors are reported directly to the template without having the
// wrapper that text/template adds.
// The "toTime" function receives a time or a Unix epoch and returns a time.Time
// in UTC. The "formatTime" function uses "toTime" and formats the resulting
// time using RFC3339. The functions "parseTime" and "mustParseTime" parse a
// string and return the time.Time it represents. The "toTimeLayout" function
// converts strings like "time.RFC3339" or "UnixDate" to the actual layout
// represented by the Go constant with the same name. The "fail" function sets
// the provided message, so that template errors are reported directly to the
// template without having the wrapper that text/template adds.
//
// {{ toTime }}
// => time.Now().UTC()
// {{ .Token.nbf | toTime }}
// => time.Unix(.Token.nbf, 0).UTC()
// {{ .Token.nbf | formatTime }}
// => time.Unix(.Token.nbf, 0).UTC().Format(time.RFC3339)
// {{ "2024-07-02T23:16:02Z" | parseTime }}
// => time.Parse(time.RFC3339, "2024-07-02T23:16:02Z")
// {{ parseTime "time.RFC339" "2024-07-02T23:16:02Z" }}
// => time.Parse(time.RFC3339, "2024-07-02T23:16:02Z")
// {{ parseTime "time.UnixDate" "Tue Jul 2 16:20:48 PDT 2024" "America/Los_Angeles" }}
// => loc, _ := time.LoadLocation("America/Los_Angeles")
// time.ParseInLocation(time.UnixDate, "Tue Jul 2 16:20:48 PDT 2024", loc)
// {{ toTimeLayout "RFC3339" }}
// => time.RFC3339
//
// sprig "env" and "expandenv" functions are removed to avoid the leak of
// information.
Expand All @@ -27,11 +49,15 @@ func GetFuncMap(failMessage *string) template.FuncMap {
*failMessage = msg
return "", errors.New(msg)
}
m["formatTime"] = formatTime
m["toTime"] = toTime
m["parseTime"] = parseTime
m["mustParseTime"] = mustParseTime
m["toTimeLayout"] = toTimeLayout
return m
}

func toTime(v any) string {
func toTime(v any) time.Time {
var t time.Time
switch date := v.(type) {
case time.Time:
Expand All @@ -53,5 +79,81 @@ func toTime(v any) string {
default:
t = time.Now()
}
return t.UTC().Format(time.RFC3339)
return t.UTC()
}

func formatTime(v any) string {
return toTime(v).Format(time.RFC3339)
}

func parseTime(v ...string) time.Time {
t, _ := mustParseTime(v...)
return t
}

func mustParseTime(v ...string) (time.Time, error) {
switch len(v) {
case 0:
return time.Now().UTC(), nil
case 1:
return time.Parse(time.RFC3339, v[0])
case 2:
layout := toTimeLayout(v[0])
return time.Parse(layout, v[1])
case 3:
layout := toTimeLayout(v[0])
loc, err := time.LoadLocation(v[2])
if err != nil {
return time.Time{}, err
}
return time.ParseInLocation(layout, v[1], loc)
default:
return time.Time{}, errors.New("unsupported number of parameters")
}
}

func toTimeLayout(fmt string) string {
switch strings.ToUpper(strings.TrimPrefix(fmt, "time.")) {
case "LAYOUT":
return time.Layout
case "ANSIC":
return time.ANSIC
case "UNIXDATE":
return time.UnixDate
case "RUBYDATE":
return time.RubyDate
case "RFC822":
return time.RFC822
case "RFC822Z":
return time.RFC822Z
case "RFC850":
return time.RFC850
case "RFC1123":
return time.RFC1123
case "RFC1123Z":
return time.RFC1123Z
case "RFC3339":
return time.RFC3339
case "RFC3339NANO":
return time.RFC3339Nano
// From the ones below, only time.DateTime will parse a complete date.
case "KITCHEN":
return time.Kitchen
case "STAMP":
return time.Stamp
case "STAMPMILLI":
return time.StampMilli
case "STAMPMICRO":
return time.StampMicro
case "STAMPNANO":
return time.StampNano
case "DATETIME":
return time.DateTime
case "DATEONLY":
return time.DateOnly
case "TIMEONLY":
return time.TimeOnly
default:
return fmt
}
}
193 changes: 183 additions & 10 deletions internal/templates/funcmap_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package templates

import (
"bytes"
"errors"
"strconv"
"strings"
"testing"
"text/template"
"time"

"github.com/stretchr/testify/assert"
Expand All @@ -26,10 +30,10 @@ func Test_GetFuncMap_fail(t *testing.T) {
}
}

func TestGetFuncMap_toTime(t *testing.T) {
now := time.Now()
func TestGetFuncMap_toTime_formatTime(t *testing.T) {
now := time.Now().Truncate(time.Second)
numericDate := jose.NewNumericDate(now)
expected := now.UTC().Format(time.RFC3339)
expected := now.UTC()
loc, err := time.LoadLocation("America/Los_Angeles")
require.NoError(t, err)

Expand All @@ -39,7 +43,7 @@ func TestGetFuncMap_toTime(t *testing.T) {
tests := []struct {
name string
args args
want string
want time.Time
}{
{"time", args{now}, expected},
{"time pointer", args{&now}, expected},
Expand All @@ -57,19 +61,188 @@ func TestGetFuncMap_toTime(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
var failMesage string
fns := GetFuncMap(&failMesage)
fn := fns["toTime"].(func(any) string)
assert.Equal(t, tt.want, fn(tt.args.v))
toTimeFunc := fns["toTime"].(func(any) time.Time)
assert.Equal(t, tt.want, toTimeFunc(tt.args.v))
formatTimeFunc := fns["formatTime"].(func(any) string)
assert.Equal(t, tt.want.Format(time.RFC3339), formatTimeFunc(tt.args.v))
})
}

t.Run("default", func(t *testing.T) {
var failMesage string
fns := GetFuncMap(&failMesage)
fn := fns["toTime"].(func(any) string)
want := time.Now()
got, err := time.Parse(time.RFC3339, fn(nil))
toTimeFunc := fns["toTime"].(func(any) time.Time)
got := toTimeFunc(nil)
assert.WithinDuration(t, time.Now(), got, time.Second)

formatTimeFunc := fns["formatTime"].(func(any) string)
got, err := time.Parse(time.RFC3339, formatTimeFunc(nil))
require.NoError(t, err)
assert.WithinDuration(t, want, got, time.Second)
assert.WithinDuration(t, time.Now(), got, time.Second)
assert.Equal(t, time.UTC, got.Location())
})
}

func TestGetFuncMap_parseTime_mustParseTime(t *testing.T) {
now := time.Now().Truncate(time.Second)
loc := time.Local
if zone, _ := now.Zone(); zone == "UTC" {
loc = time.UTC
}

losAngeles, err := time.LoadLocation("America/Los_Angeles")
require.NoError(t, err)

type args struct {
v []string
}
tests := []struct {
name string
args args
want time.Time
assertion assert.ErrorAssertionFunc
}{
{"now", args{[]string{now.Format(time.RFC3339)}}, now.In(loc), assert.NoError},
{"with real layout", args{[]string{time.UnixDate, now.UTC().Format(time.UnixDate)}}, now.UTC(), assert.NoError},
{"with name layout", args{[]string{"time.UnixDate", now.Format(time.UnixDate)}}, now.In(loc), assert.NoError},
{"with locale UTC", args{[]string{"time.UnixDate", now.UTC().Format(time.UnixDate), "UTC"}}, now.UTC(), assert.NoError},
{"with locale other", args{[]string{"time.UnixDate", now.In(losAngeles).Format(time.UnixDate), "America/Los_Angeles"}}, now.In(losAngeles), assert.NoError},
{"fail parse", args{[]string{now.Format(time.UnixDate)}}, time.Time{}, assert.Error},
{"fail parse with layout", args{[]string{"time.UnixDate", now.Format(time.RFC3339)}}, time.Time{}, assert.Error},
{"fail parse with locale", args{[]string{"time.UnixDate", now.Format(time.RFC3339), "america/Los_Angeles"}}, time.Time{}, assert.Error},
{"fail load locale", args{[]string{"time.UnixDate", now.In(losAngeles).Format(time.UnixDate), "America/The_Angels"}}, time.Time{}, assert.Error},
{"fail arguments", args{[]string{"time.Layout", now.Format(time.Layout), "America/The_Angels", "extra"}}, time.Time{}, assert.Error},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var failMesage string
fns := GetFuncMap(&failMesage)
parseTimeFunc := fns["parseTime"].(func(...string) time.Time)
assert.Equal(t, tt.want, parseTimeFunc(tt.args.v...))

mustParseTimeFunc := fns["mustParseTime"].(func(...string) (time.Time, error))
got, err := mustParseTimeFunc(tt.args.v...)
tt.assertion(t, err)
assert.Equal(t, tt.want, got)
})
}

t.Run("default", func(t *testing.T) {
var failMesage string
fns := GetFuncMap(&failMesage)
parseTimeFunc := fns["parseTime"].(func(...string) time.Time)
got := parseTimeFunc()
assert.WithinDuration(t, time.Now(), got, time.Second)

mustParseTimeFunc := fns["mustParseTime"].(func(...string) (time.Time, error))
got, err := mustParseTimeFunc()
require.NoError(t, err)
assert.WithinDuration(t, time.Now(), got, time.Second)
assert.Equal(t, time.UTC, got.Location())
})
}

func TestGetFuncMap_toTimeLayout(t *testing.T) {
type args struct {
fmt string
}
tests := []struct {
name string
args args
want string
}{
{"format", args{time.RFC3339}, time.RFC3339},
{"time.Layout", args{"time.Layout"}, time.Layout},
{"time.ANSIC", args{"time.ANSIC"}, time.ANSIC},
{"time.UnixDate", args{"time.UnixDate"}, time.UnixDate},
{"time.RubyDate", args{"time.RubyDate"}, time.RubyDate},
{"time.RFC822", args{"time.RFC822"}, time.RFC822},
{"time.RFC822Z", args{"time.RFC822Z"}, time.RFC822Z},
{"time.RFC850", args{"time.RFC850"}, time.RFC850},
{"time.RFC1123", args{"time.RFC1123"}, time.RFC1123},
{"time.RFC1123Z", args{"time.RFC1123Z"}, time.RFC1123Z},
{"time.RFC3339", args{"time.RFC3339"}, time.RFC3339},
{"time.RFC3339Nano", args{"time.RFC3339Nano"}, time.RFC3339Nano},
{"time.Kitchen", args{"time.Kitchen"}, time.Kitchen},
{"time.Stamp", args{"time.Stamp"}, time.Stamp},
{"time.StampMilli", args{"time.StampMilli"}, time.StampMilli},
{"time.StampMicro", args{"time.StampMicro"}, time.StampMicro},
{"time.StampNano", args{"time.StampNano"}, time.StampNano},
{"time.DateTime", args{"time.DateTime"}, time.DateTime},
{"time.DateOnly", args{"time.DateOnly"}, time.DateOnly},
{"time.TimeOnly", args{"time.TimeOnly"}, time.TimeOnly},
{"uppercase", args{"UNIXDATE"}, time.UnixDate},
{"lowercase", args{"rfc3339"}, time.RFC3339},
{"default", args{"MyFormat"}, "MyFormat"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var failMesage string
fns := GetFuncMap(&failMesage)
toTimeLayoutFunc := fns["toTimeLayout"].(func(string) string)
assert.Equal(t, tt.want, toTimeLayoutFunc(tt.args.fmt))
fmt := strings.TrimPrefix(tt.args.fmt, "time.")
assert.Equal(t, tt.want, toTimeLayoutFunc(fmt))
})
}
}

func TestTemplates(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
mustParse := func(t *testing.T, text string, msg *string, assertion assert.ErrorAssertionFunc) string {
t.Helper()

tmpl, err := template.New(t.Name()).Funcs(GetFuncMap(msg)).Parse(text)
require.NoError(t, err)
buf := new(bytes.Buffer)
err = tmpl.Execute(buf, map[string]any{
"nbf": now.Unix(),
"float64": float64(now.Unix()),
"notBefore": now.Format(time.RFC3339),
"notAfter": now.Add(time.Hour).Format(time.UnixDate),
})
assertion(t, err)
return buf.String()
}

type args struct {
text string
}
tests := []struct {
name string
args args
want string
errorAssertion assert.ErrorAssertionFunc
failAssertion assert.ValueAssertionFunc
}{
{"toTime int64", args{`{{ .nbf | toTime }}`}, now.String(), assert.NoError, assert.Empty},
{"toTime int64 toJson", args{`{{ .nbf | toTime | toJson }}`}, strconv.Quote(now.Format(time.RFC3339)), assert.NoError, assert.Empty},
{"toTime float64 toJson", args{`{{ .float64 | toTime | toJson }}`}, strconv.Quote(now.Format(time.RFC3339)), assert.NoError, assert.Empty},
{"toTime dateModify", args{`{{ .nbf | toTime | dateModify "1h" }}`}, now.Add(time.Hour).String(), assert.NoError, assert.Empty},
{"formatTime", args{`{{ .nbf | formatTime }}`}, now.Format(time.RFC3339), assert.NoError, assert.Empty},
{"formatTime float64", args{`{{ .float64 | formatTime }}`}, now.Format(time.RFC3339), assert.NoError, assert.Empty},
{"formatTime in sprig", args{`{{ dateInZone "2006-01-02T15:04:05Z07:00" .float64 "UTC" }}`}, now.UTC().Format(time.RFC3339), assert.NoError, assert.Empty},
{"parseTime", args{`{{ .notBefore | parseTime }}`}, now.String(), assert.NoError, assert.Empty},
{"parseTime toJson", args{`{{ .notBefore | parseTime | toJson }}`}, strconv.Quote(now.Format(time.RFC3339)), assert.NoError, assert.Empty},
{"parseTime time.UnixDate", args{`{{ .notAfter | parseTime "time.UnixDate" }}`}, now.Add(time.Hour).String(), assert.NoError, assert.Empty},
{"parseTime time.UnixDate toJson", args{`{{ .notAfter | parseTime "time.UnixDate" | toJson }}`}, strconv.Quote(now.Add(time.Hour).Format(time.RFC3339)), assert.NoError, assert.Empty},
{"parseTime time.UnixDate America/Los_Angeles", args{`{{ parseTime "time.UnixDate" .notAfter "America/Los_Angeles" }}`}, now.Add(time.Hour).String(), assert.NoError, assert.Empty},
{"parseTime dateModify", args{`{{ .notBefore | parseTime | dateModify "1h" }}`}, now.Add(time.Hour).String(), assert.NoError, assert.Empty},
{"parseTime in sprig ", args{`{{ toDate "Mon Jan _2 15:04:05 MST 2006" .notAfter }}`}, now.Add(time.Hour).String(), assert.NoError, assert.Empty},
{"toTimeLayout", args{`{{ toTimeLayout "time.RFC3339" }}`}, time.RFC3339, assert.NoError, assert.Empty},
{"toTimeLayout short", args{`{{ toTimeLayout "RFC3339" }}`}, time.RFC3339, assert.NoError, assert.Empty},
{"toTime toTimeLayout date", args{`{{ .nbf | toTime | date (toTimeLayout "time.RFC3339") }}`}, now.Local().Format(time.RFC3339), assert.NoError, assert.Empty},
{"toTime toTimeLayout date", args{`{{ .nbf | toTime | date (toTimeLayout "time.RFC3339") }}`}, now.Local().Format(time.RFC3339), assert.NoError, assert.Empty},
{"parseTime error", args{`{{ parseTime "time.UnixDate" .notAfter "America/FooBar" }}`}, "0001-01-01 00:00:00 +0000 UTC", assert.NoError, assert.Empty},
{"mustParseTime error", args{`{{ mustParseTime "time.UnixDate" .notAfter "America/FooBar" }}`}, "", assert.Error, assert.Empty},
{"fail", args{`{{ fail "error" }}`}, "", assert.Error, assert.NotEmpty},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var failMesage string
got := mustParse(t, tt.args.text, &failMesage, tt.errorAssertion)
tt.failAssertion(t, failMesage)
assert.Equal(t, tt.want, got)
})
}
}
2 changes: 1 addition & 1 deletion x509util/certificate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ func TestNewCertificateTemplate(t *testing.T) {
(dict "type" "userPrincipalName" "value" .Token.upn)
(dict "type" "1.2.3.4" "value" (printf "int:%s" .Insecure.User.id))
) | toJson }},
"notBefore": "{{ .Token.nbf | toTime }}",
"notBefore": "{{ .Token.nbf | formatTime }}",
"notAfter": {{ now | dateModify "24h" | toJson }},
{{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }}
"keyUsage": ["keyEncipherment", "digitalSignature"],
Expand Down

0 comments on commit 446f4e2

Please sign in to comment.