diff --git a/internal/templates/funcmap.go b/internal/templates/funcmap.go index 76dd6e70..45c13759 100644 --- a/internal/templates/funcmap.go +++ b/internal/templates/funcmap.go @@ -2,6 +2,7 @@ package templates import ( "errors" + "strings" "text/template" "time" @@ -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. @@ -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: @@ -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 + } } diff --git a/internal/templates/funcmap_test.go b/internal/templates/funcmap_test.go index 924b306f..e70afb5b 100644 --- a/internal/templates/funcmap_test.go +++ b/internal/templates/funcmap_test.go @@ -1,8 +1,12 @@ package templates import ( + "bytes" "errors" + "strconv" + "strings" "testing" + "text/template" "time" "github.com/stretchr/testify/assert" @@ -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) @@ -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}, @@ -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) + }) + } +} diff --git a/x509util/certificate_test.go b/x509util/certificate_test.go index 5fc6b83d..7c04eaac 100644 --- a/x509util/certificate_test.go +++ b/x509util/certificate_test.go @@ -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"],