Skip to content

Commit 446f4e2

Browse files
authored
Merge pull request #539 from smallstep/mariano/timeFunctions
Improve time functions
2 parents 1225540 + 329b3f1 commit 446f4e2

File tree

3 files changed

+293
-18
lines changed

3 files changed

+293
-18
lines changed

internal/templates/funcmap.go

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package templates
22

33
import (
44
"errors"
5+
"strings"
56
"text/template"
67
"time"
78

@@ -10,12 +11,33 @@ import (
1011
)
1112

1213
// GetFuncMap returns the list of functions provided by sprig. It adds the
13-
// function "toTime" and changes the function "fail".
14+
// functions "toTime", "formatTime", "parseTime", "mustParseTime",
15+
// "toTimeLayout" and changes the function "fail".
1416
//
15-
// The "toTime" function receives a time or a Unix epoch and formats it to
16-
// RFC3339 in UTC. The "fail" function sets the provided message, so that
17-
// template errors are reported directly to the template without having the
18-
// wrapper that text/template adds.
17+
// The "toTime" function receives a time or a Unix epoch and returns a time.Time
18+
// in UTC. The "formatTime" function uses "toTime" and formats the resulting
19+
// time using RFC3339. The functions "parseTime" and "mustParseTime" parse a
20+
// string and return the time.Time it represents. The "toTimeLayout" function
21+
// converts strings like "time.RFC3339" or "UnixDate" to the actual layout
22+
// represented by the Go constant with the same name. The "fail" function sets
23+
// the provided message, so that template errors are reported directly to the
24+
// template without having the wrapper that text/template adds.
25+
//
26+
// {{ toTime }}
27+
// => time.Now().UTC()
28+
// {{ .Token.nbf | toTime }}
29+
// => time.Unix(.Token.nbf, 0).UTC()
30+
// {{ .Token.nbf | formatTime }}
31+
// => time.Unix(.Token.nbf, 0).UTC().Format(time.RFC3339)
32+
// {{ "2024-07-02T23:16:02Z" | parseTime }}
33+
// => time.Parse(time.RFC3339, "2024-07-02T23:16:02Z")
34+
// {{ parseTime "time.RFC339" "2024-07-02T23:16:02Z" }}
35+
// => time.Parse(time.RFC3339, "2024-07-02T23:16:02Z")
36+
// {{ parseTime "time.UnixDate" "Tue Jul 2 16:20:48 PDT 2024" "America/Los_Angeles" }}
37+
// => loc, _ := time.LoadLocation("America/Los_Angeles")
38+
// time.ParseInLocation(time.UnixDate, "Tue Jul 2 16:20:48 PDT 2024", loc)
39+
// {{ toTimeLayout "RFC3339" }}
40+
// => time.RFC3339
1941
//
2042
// sprig "env" and "expandenv" functions are removed to avoid the leak of
2143
// information.
@@ -27,11 +49,15 @@ func GetFuncMap(failMessage *string) template.FuncMap {
2749
*failMessage = msg
2850
return "", errors.New(msg)
2951
}
52+
m["formatTime"] = formatTime
3053
m["toTime"] = toTime
54+
m["parseTime"] = parseTime
55+
m["mustParseTime"] = mustParseTime
56+
m["toTimeLayout"] = toTimeLayout
3157
return m
3258
}
3359

34-
func toTime(v any) string {
60+
func toTime(v any) time.Time {
3561
var t time.Time
3662
switch date := v.(type) {
3763
case time.Time:
@@ -53,5 +79,81 @@ func toTime(v any) string {
5379
default:
5480
t = time.Now()
5581
}
56-
return t.UTC().Format(time.RFC3339)
82+
return t.UTC()
83+
}
84+
85+
func formatTime(v any) string {
86+
return toTime(v).Format(time.RFC3339)
87+
}
88+
89+
func parseTime(v ...string) time.Time {
90+
t, _ := mustParseTime(v...)
91+
return t
92+
}
93+
94+
func mustParseTime(v ...string) (time.Time, error) {
95+
switch len(v) {
96+
case 0:
97+
return time.Now().UTC(), nil
98+
case 1:
99+
return time.Parse(time.RFC3339, v[0])
100+
case 2:
101+
layout := toTimeLayout(v[0])
102+
return time.Parse(layout, v[1])
103+
case 3:
104+
layout := toTimeLayout(v[0])
105+
loc, err := time.LoadLocation(v[2])
106+
if err != nil {
107+
return time.Time{}, err
108+
}
109+
return time.ParseInLocation(layout, v[1], loc)
110+
default:
111+
return time.Time{}, errors.New("unsupported number of parameters")
112+
}
113+
}
114+
115+
func toTimeLayout(fmt string) string {
116+
switch strings.ToUpper(strings.TrimPrefix(fmt, "time.")) {
117+
case "LAYOUT":
118+
return time.Layout
119+
case "ANSIC":
120+
return time.ANSIC
121+
case "UNIXDATE":
122+
return time.UnixDate
123+
case "RUBYDATE":
124+
return time.RubyDate
125+
case "RFC822":
126+
return time.RFC822
127+
case "RFC822Z":
128+
return time.RFC822Z
129+
case "RFC850":
130+
return time.RFC850
131+
case "RFC1123":
132+
return time.RFC1123
133+
case "RFC1123Z":
134+
return time.RFC1123Z
135+
case "RFC3339":
136+
return time.RFC3339
137+
case "RFC3339NANO":
138+
return time.RFC3339Nano
139+
// From the ones below, only time.DateTime will parse a complete date.
140+
case "KITCHEN":
141+
return time.Kitchen
142+
case "STAMP":
143+
return time.Stamp
144+
case "STAMPMILLI":
145+
return time.StampMilli
146+
case "STAMPMICRO":
147+
return time.StampMicro
148+
case "STAMPNANO":
149+
return time.StampNano
150+
case "DATETIME":
151+
return time.DateTime
152+
case "DATEONLY":
153+
return time.DateOnly
154+
case "TIMEONLY":
155+
return time.TimeOnly
156+
default:
157+
return fmt
158+
}
57159
}

internal/templates/funcmap_test.go

Lines changed: 183 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package templates
22

33
import (
4+
"bytes"
45
"errors"
6+
"strconv"
7+
"strings"
58
"testing"
9+
"text/template"
610
"time"
711

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

29-
func TestGetFuncMap_toTime(t *testing.T) {
30-
now := time.Now()
33+
func TestGetFuncMap_toTime_formatTime(t *testing.T) {
34+
now := time.Now().Truncate(time.Second)
3135
numericDate := jose.NewNumericDate(now)
32-
expected := now.UTC().Format(time.RFC3339)
36+
expected := now.UTC()
3337
loc, err := time.LoadLocation("America/Los_Angeles")
3438
require.NoError(t, err)
3539

@@ -39,7 +43,7 @@ func TestGetFuncMap_toTime(t *testing.T) {
3943
tests := []struct {
4044
name string
4145
args args
42-
want string
46+
want time.Time
4347
}{
4448
{"time", args{now}, expected},
4549
{"time pointer", args{&now}, expected},
@@ -57,19 +61,188 @@ func TestGetFuncMap_toTime(t *testing.T) {
5761
t.Run(tt.name, func(t *testing.T) {
5862
var failMesage string
5963
fns := GetFuncMap(&failMesage)
60-
fn := fns["toTime"].(func(any) string)
61-
assert.Equal(t, tt.want, fn(tt.args.v))
64+
toTimeFunc := fns["toTime"].(func(any) time.Time)
65+
assert.Equal(t, tt.want, toTimeFunc(tt.args.v))
66+
formatTimeFunc := fns["formatTime"].(func(any) string)
67+
assert.Equal(t, tt.want.Format(time.RFC3339), formatTimeFunc(tt.args.v))
6268
})
6369
}
6470

6571
t.Run("default", func(t *testing.T) {
6672
var failMesage string
6773
fns := GetFuncMap(&failMesage)
68-
fn := fns["toTime"].(func(any) string)
69-
want := time.Now()
70-
got, err := time.Parse(time.RFC3339, fn(nil))
74+
toTimeFunc := fns["toTime"].(func(any) time.Time)
75+
got := toTimeFunc(nil)
76+
assert.WithinDuration(t, time.Now(), got, time.Second)
77+
78+
formatTimeFunc := fns["formatTime"].(func(any) string)
79+
got, err := time.Parse(time.RFC3339, formatTimeFunc(nil))
7180
require.NoError(t, err)
72-
assert.WithinDuration(t, want, got, time.Second)
81+
assert.WithinDuration(t, time.Now(), got, time.Second)
7382
assert.Equal(t, time.UTC, got.Location())
7483
})
7584
}
85+
86+
func TestGetFuncMap_parseTime_mustParseTime(t *testing.T) {
87+
now := time.Now().Truncate(time.Second)
88+
loc := time.Local
89+
if zone, _ := now.Zone(); zone == "UTC" {
90+
loc = time.UTC
91+
}
92+
93+
losAngeles, err := time.LoadLocation("America/Los_Angeles")
94+
require.NoError(t, err)
95+
96+
type args struct {
97+
v []string
98+
}
99+
tests := []struct {
100+
name string
101+
args args
102+
want time.Time
103+
assertion assert.ErrorAssertionFunc
104+
}{
105+
{"now", args{[]string{now.Format(time.RFC3339)}}, now.In(loc), assert.NoError},
106+
{"with real layout", args{[]string{time.UnixDate, now.UTC().Format(time.UnixDate)}}, now.UTC(), assert.NoError},
107+
{"with name layout", args{[]string{"time.UnixDate", now.Format(time.UnixDate)}}, now.In(loc), assert.NoError},
108+
{"with locale UTC", args{[]string{"time.UnixDate", now.UTC().Format(time.UnixDate), "UTC"}}, now.UTC(), assert.NoError},
109+
{"with locale other", args{[]string{"time.UnixDate", now.In(losAngeles).Format(time.UnixDate), "America/Los_Angeles"}}, now.In(losAngeles), assert.NoError},
110+
{"fail parse", args{[]string{now.Format(time.UnixDate)}}, time.Time{}, assert.Error},
111+
{"fail parse with layout", args{[]string{"time.UnixDate", now.Format(time.RFC3339)}}, time.Time{}, assert.Error},
112+
{"fail parse with locale", args{[]string{"time.UnixDate", now.Format(time.RFC3339), "america/Los_Angeles"}}, time.Time{}, assert.Error},
113+
{"fail load locale", args{[]string{"time.UnixDate", now.In(losAngeles).Format(time.UnixDate), "America/The_Angels"}}, time.Time{}, assert.Error},
114+
{"fail arguments", args{[]string{"time.Layout", now.Format(time.Layout), "America/The_Angels", "extra"}}, time.Time{}, assert.Error},
115+
}
116+
for _, tt := range tests {
117+
t.Run(tt.name, func(t *testing.T) {
118+
var failMesage string
119+
fns := GetFuncMap(&failMesage)
120+
parseTimeFunc := fns["parseTime"].(func(...string) time.Time)
121+
assert.Equal(t, tt.want, parseTimeFunc(tt.args.v...))
122+
123+
mustParseTimeFunc := fns["mustParseTime"].(func(...string) (time.Time, error))
124+
got, err := mustParseTimeFunc(tt.args.v...)
125+
tt.assertion(t, err)
126+
assert.Equal(t, tt.want, got)
127+
})
128+
}
129+
130+
t.Run("default", func(t *testing.T) {
131+
var failMesage string
132+
fns := GetFuncMap(&failMesage)
133+
parseTimeFunc := fns["parseTime"].(func(...string) time.Time)
134+
got := parseTimeFunc()
135+
assert.WithinDuration(t, time.Now(), got, time.Second)
136+
137+
mustParseTimeFunc := fns["mustParseTime"].(func(...string) (time.Time, error))
138+
got, err := mustParseTimeFunc()
139+
require.NoError(t, err)
140+
assert.WithinDuration(t, time.Now(), got, time.Second)
141+
assert.Equal(t, time.UTC, got.Location())
142+
})
143+
}
144+
145+
func TestGetFuncMap_toTimeLayout(t *testing.T) {
146+
type args struct {
147+
fmt string
148+
}
149+
tests := []struct {
150+
name string
151+
args args
152+
want string
153+
}{
154+
{"format", args{time.RFC3339}, time.RFC3339},
155+
{"time.Layout", args{"time.Layout"}, time.Layout},
156+
{"time.ANSIC", args{"time.ANSIC"}, time.ANSIC},
157+
{"time.UnixDate", args{"time.UnixDate"}, time.UnixDate},
158+
{"time.RubyDate", args{"time.RubyDate"}, time.RubyDate},
159+
{"time.RFC822", args{"time.RFC822"}, time.RFC822},
160+
{"time.RFC822Z", args{"time.RFC822Z"}, time.RFC822Z},
161+
{"time.RFC850", args{"time.RFC850"}, time.RFC850},
162+
{"time.RFC1123", args{"time.RFC1123"}, time.RFC1123},
163+
{"time.RFC1123Z", args{"time.RFC1123Z"}, time.RFC1123Z},
164+
{"time.RFC3339", args{"time.RFC3339"}, time.RFC3339},
165+
{"time.RFC3339Nano", args{"time.RFC3339Nano"}, time.RFC3339Nano},
166+
{"time.Kitchen", args{"time.Kitchen"}, time.Kitchen},
167+
{"time.Stamp", args{"time.Stamp"}, time.Stamp},
168+
{"time.StampMilli", args{"time.StampMilli"}, time.StampMilli},
169+
{"time.StampMicro", args{"time.StampMicro"}, time.StampMicro},
170+
{"time.StampNano", args{"time.StampNano"}, time.StampNano},
171+
{"time.DateTime", args{"time.DateTime"}, time.DateTime},
172+
{"time.DateOnly", args{"time.DateOnly"}, time.DateOnly},
173+
{"time.TimeOnly", args{"time.TimeOnly"}, time.TimeOnly},
174+
{"uppercase", args{"UNIXDATE"}, time.UnixDate},
175+
{"lowercase", args{"rfc3339"}, time.RFC3339},
176+
{"default", args{"MyFormat"}, "MyFormat"},
177+
}
178+
for _, tt := range tests {
179+
t.Run(tt.name, func(t *testing.T) {
180+
var failMesage string
181+
fns := GetFuncMap(&failMesage)
182+
toTimeLayoutFunc := fns["toTimeLayout"].(func(string) string)
183+
assert.Equal(t, tt.want, toTimeLayoutFunc(tt.args.fmt))
184+
fmt := strings.TrimPrefix(tt.args.fmt, "time.")
185+
assert.Equal(t, tt.want, toTimeLayoutFunc(fmt))
186+
})
187+
}
188+
}
189+
190+
func TestTemplates(t *testing.T) {
191+
now := time.Now().UTC().Truncate(time.Second)
192+
mustParse := func(t *testing.T, text string, msg *string, assertion assert.ErrorAssertionFunc) string {
193+
t.Helper()
194+
195+
tmpl, err := template.New(t.Name()).Funcs(GetFuncMap(msg)).Parse(text)
196+
require.NoError(t, err)
197+
buf := new(bytes.Buffer)
198+
err = tmpl.Execute(buf, map[string]any{
199+
"nbf": now.Unix(),
200+
"float64": float64(now.Unix()),
201+
"notBefore": now.Format(time.RFC3339),
202+
"notAfter": now.Add(time.Hour).Format(time.UnixDate),
203+
})
204+
assertion(t, err)
205+
return buf.String()
206+
}
207+
208+
type args struct {
209+
text string
210+
}
211+
tests := []struct {
212+
name string
213+
args args
214+
want string
215+
errorAssertion assert.ErrorAssertionFunc
216+
failAssertion assert.ValueAssertionFunc
217+
}{
218+
{"toTime int64", args{`{{ .nbf | toTime }}`}, now.String(), assert.NoError, assert.Empty},
219+
{"toTime int64 toJson", args{`{{ .nbf | toTime | toJson }}`}, strconv.Quote(now.Format(time.RFC3339)), assert.NoError, assert.Empty},
220+
{"toTime float64 toJson", args{`{{ .float64 | toTime | toJson }}`}, strconv.Quote(now.Format(time.RFC3339)), assert.NoError, assert.Empty},
221+
{"toTime dateModify", args{`{{ .nbf | toTime | dateModify "1h" }}`}, now.Add(time.Hour).String(), assert.NoError, assert.Empty},
222+
{"formatTime", args{`{{ .nbf | formatTime }}`}, now.Format(time.RFC3339), assert.NoError, assert.Empty},
223+
{"formatTime float64", args{`{{ .float64 | formatTime }}`}, now.Format(time.RFC3339), assert.NoError, assert.Empty},
224+
{"formatTime in sprig", args{`{{ dateInZone "2006-01-02T15:04:05Z07:00" .float64 "UTC" }}`}, now.UTC().Format(time.RFC3339), assert.NoError, assert.Empty},
225+
{"parseTime", args{`{{ .notBefore | parseTime }}`}, now.String(), assert.NoError, assert.Empty},
226+
{"parseTime toJson", args{`{{ .notBefore | parseTime | toJson }}`}, strconv.Quote(now.Format(time.RFC3339)), assert.NoError, assert.Empty},
227+
{"parseTime time.UnixDate", args{`{{ .notAfter | parseTime "time.UnixDate" }}`}, now.Add(time.Hour).String(), assert.NoError, assert.Empty},
228+
{"parseTime time.UnixDate toJson", args{`{{ .notAfter | parseTime "time.UnixDate" | toJson }}`}, strconv.Quote(now.Add(time.Hour).Format(time.RFC3339)), assert.NoError, assert.Empty},
229+
{"parseTime time.UnixDate America/Los_Angeles", args{`{{ parseTime "time.UnixDate" .notAfter "America/Los_Angeles" }}`}, now.Add(time.Hour).String(), assert.NoError, assert.Empty},
230+
{"parseTime dateModify", args{`{{ .notBefore | parseTime | dateModify "1h" }}`}, now.Add(time.Hour).String(), assert.NoError, assert.Empty},
231+
{"parseTime in sprig ", args{`{{ toDate "Mon Jan _2 15:04:05 MST 2006" .notAfter }}`}, now.Add(time.Hour).String(), assert.NoError, assert.Empty},
232+
{"toTimeLayout", args{`{{ toTimeLayout "time.RFC3339" }}`}, time.RFC3339, assert.NoError, assert.Empty},
233+
{"toTimeLayout short", args{`{{ toTimeLayout "RFC3339" }}`}, time.RFC3339, assert.NoError, assert.Empty},
234+
{"toTime toTimeLayout date", args{`{{ .nbf | toTime | date (toTimeLayout "time.RFC3339") }}`}, now.Local().Format(time.RFC3339), assert.NoError, assert.Empty},
235+
{"toTime toTimeLayout date", args{`{{ .nbf | toTime | date (toTimeLayout "time.RFC3339") }}`}, now.Local().Format(time.RFC3339), assert.NoError, assert.Empty},
236+
{"parseTime error", args{`{{ parseTime "time.UnixDate" .notAfter "America/FooBar" }}`}, "0001-01-01 00:00:00 +0000 UTC", assert.NoError, assert.Empty},
237+
{"mustParseTime error", args{`{{ mustParseTime "time.UnixDate" .notAfter "America/FooBar" }}`}, "", assert.Error, assert.Empty},
238+
{"fail", args{`{{ fail "error" }}`}, "", assert.Error, assert.NotEmpty},
239+
}
240+
for _, tt := range tests {
241+
t.Run(tt.name, func(t *testing.T) {
242+
var failMesage string
243+
got := mustParse(t, tt.args.text, &failMesage, tt.errorAssertion)
244+
tt.failAssertion(t, failMesage)
245+
assert.Equal(t, tt.want, got)
246+
})
247+
}
248+
}

x509util/certificate_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ func TestNewCertificateTemplate(t *testing.T) {
320320
(dict "type" "userPrincipalName" "value" .Token.upn)
321321
(dict "type" "1.2.3.4" "value" (printf "int:%s" .Insecure.User.id))
322322
) | toJson }},
323-
"notBefore": "{{ .Token.nbf | toTime }}",
323+
"notBefore": "{{ .Token.nbf | formatTime }}",
324324
"notAfter": {{ now | dateModify "24h" | toJson }},
325325
{{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }}
326326
"keyUsage": ["keyEncipherment", "digitalSignature"],

0 commit comments

Comments
 (0)