Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
"signatures": [
{
"keyid": "74b58be26a6ff00ab2eec9b14da29038591a69c212223033f4efdf24489913f2",
"sig": "60d502a798f44577a76a1d7656a60099cd3a995d3d71a0a234aadbd16e38c14611920b8aef9ed78ca4ac0c02277cd72a6fc5ef484a3d66a6c70a61199e462681eb2e667046d4fbc2be1e50cf9fe00fda8fcd6534599eddc91716bf38e7fbbf375524fdb702c74076fd37dcd401d5263783150e851bdba9ef6f9c9a08adf6b289"
"sig": "198d9832af271bd30c1e5ba231a6d2cd357043757c21037ed414c3ee51f11957fb9f525ef743e38f3184d479e026dcc5159a88d75b45c48d265132be092775de64fae540756c03e627b55312cfbe77f5755a044189a9efeaba9cbe2c7d84580f606756881e45b025cebf04a0898ab55aa99081aed7699f75983737d63858171e"
}
],
"signed": {
"_type": "root",
"consistent_snapshot": true,
"expires": "2030-08-15T14:30:45.0000001Z",
"expires": "2030-08-15T14:30:45Z",
"keys": {
"142919f8e933d7045abff3be450070057814da36331d7a22ccade8b35a9e3946": {
"keytype": "rsa",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
"signatures": [
{
"keyid": "8a14f637b21578cc292a67899df0e46cc160d7fd56e9beae898adb666f4fd9d6",
"sig": "1fdc41488f58482d8af1dad681b9076d54c61b3f5e11bd6cf3c8102b2863471f82c0be2cccd978a9bb6afb43e07dd7e806028a883eaeafd32d5f5277d6419363ecb1475286a61996a4bb4b325b703d3bd60381227af0a3826f7f119a451086bcb5b13a525184d1b2a941ab9a270d2c9c8e584162c5857b138a38c33892e2a921"
"sig": "06fbee223da0becf620e99dc8a68e127c475a3818adaa76c73bf3650baa3768c89dab60f701c8621f808891f30ebfce31ee4c1859cbf4a6e2e08990364d930755713142efe9e244e0ea3ad7468d7c1540f452cd8d890f8899363952bce943b5dd3e2e5918f81d7f1b1e91d2269b61a042611d9150ebfe79f4fca1fdca0006ca3"
}
],
"signed": {
"_type": "snapshot",
"expires": "2030-08-15T14:30:45.0000001Z",
"expires": "2030-08-15T14:30:45Z",
"meta": {
"role1.json": {
"version": 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"signatures": [
{
"keyid": "282612f348dcd7fe3f19e0f890e89fad48d45335deeb91deef92873934e6fe6d",
"sig": "699c0c762032f6471fceabfd2d61b80f352ad6bf2ba5fd7b114fd0b9b1ab8d94f1482776b3fef43c53183a9c9cd7f5de671cbdafdd5032bbe2c42273e953bf3ce9f99c2d46dac8802d6155082e10313e22c4886af2be113b626f2a8af930e01ed41df50a5dbe6ca4cedf5f5d2a7f3b7e7090abacc8aebd6e021ad021d3580cad"
"sig": "2f887b7661d41f0a695b543feb8e9928420548fa45e4e0a9585cbce85fcd75d1a4787c0847045c38798cd2cc322ca322f3ac49293e311b60455c815aeab7cda6241cea5380f85ff0d2fb9ebe29feb3cb3600c7c9b203852e286842e3e7cccbb2d3836616fe58cd3fe46da127c9b12a3f6cab43a1e6dd1ea206044bc732f0128d"
}
],
"signed": {
Expand Down Expand Up @@ -35,7 +35,7 @@
}
]
},
"expires": "2030-08-15T14:30:45.0000001Z",
"expires": "2030-08-15T14:30:45Z",
"spec_version": "1.0.31",
"targets": {
"file1.txt": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
"signatures": [
{
"keyid": "142919f8e933d7045abff3be450070057814da36331d7a22ccade8b35a9e3946",
"sig": "043b312da9ee1444e3ef539d943891b2690a8d75624c0f05ec148790fd698a7eb1501428167872794857e9669a451619cc796658782b1d46ecc59d1aca0db7233416e81074ef4f54fd845ad8e4216b4cd5163d815be9ecbf73f34aacd25b60c99da88cf641ba5715c37f34a6bc036061c05a42066f554714ee8647c47ae5c16e"
"sig": "25e412c3e45775754eea566b1030ba40277bfa1b6740894c1f93abb2899d1e21690f06068d96a2197cad80b587754750d8de1e4644a7552689d13245af7794921dad00596b4c1b52b37735d873dab1131a3b408badec3414724731521c38b17bfed33d04a1573a6bd8f6510e98d66036cdecb6ff1657be9e5d59b84674f6471c"
}
],
"signed": {
"_type": "timestamp",
"expires": "2030-08-15T14:30:45.0000001Z",
"expires": "2030-08-15T14:30:45Z",
"meta": {
"snapshot.json": {
"version": 1
Expand Down
104 changes: 104 additions & 0 deletions metadata/expires_format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright 2024 The Update Framework Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License
//
// SPDX-License-Identifier: Apache-2.0
//

package metadata

import (
"encoding/json"
"regexp"
"testing"
"time"

"github.com/stretchr/testify/assert"
)

// secondPrecisionUTC matches the TUF spec date/time format for "expires":
// an ISO 8601 / RFC 3339 timestamp in UTC, truncated to whole seconds with
// a trailing Z and no fractional-second component.
var secondPrecisionUTC = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$`)

// expiresFromJSON pulls the raw "expires" string out of a marshaled signed body.
func expiresFromJSON(t *testing.T, data []byte) string {
t.Helper()
var dict map[string]any
if err := json.Unmarshal(data, &dict); err != nil {
t.Fatalf("unmarshal signed body: %v", err)
}
v, ok := dict["expires"]
if !ok {
t.Fatalf("no expires field in %s", string(data))
}
s, ok := v.(string)
if !ok {
t.Fatalf("expires is not a string: %T (%v)", v, v)
}
return s
}

// TestExpiresMarshalSecondPrecision verifies that every role type serializes
// "expires" with whole-second UTC precision (YYYY-MM-DDTHH:MM:SSZ), per the TUF
// spec, even when the in-memory time.Time carries sub-second precision.
func TestExpiresMarshalSecondPrecision(t *testing.T) {
// A time with sub-second precision (100 nanoseconds). Marshaling a raw
// time.Time would emit "2030-08-15T14:30:45.0000001Z".
subSecond := time.Date(2030, 8, 15, 14, 30, 45, 100, time.UTC)
wantExpires := "2030-08-15T14:30:45Z"

cases := []struct {
name string
body json.Marshaler
}{
{"root", Root(subSecond).Signed},
{"snapshot", Snapshot(subSecond).Signed},
{"timestamp", Timestamp(subSecond).Signed},
{"targets", Targets(subSecond).Signed},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
data, err := tc.body.MarshalJSON()
assert.NoError(t, err)

got := expiresFromJSON(t, data)
assert.Regexp(t, secondPrecisionUTC, got,
"expires must be whole-second UTC per the TUF spec, got %q", got)
assert.Equal(t, wantExpires, got)
})
}
}

// TestExpiresMarshalUnmarshalRoundTrip confirms that the second-precision
// formatting still round-trips: parsing the formatted output back yields the
// truncated time, and re-marshaling is stable.
func TestExpiresMarshalUnmarshalRoundTrip(t *testing.T) {
subSecond := time.Date(2030, 8, 15, 14, 30, 45, 100, time.UTC)
truncated := time.Date(2030, 8, 15, 14, 30, 45, 0, time.UTC)

root := Root(subSecond)
data, err := root.Signed.MarshalJSON()
assert.NoError(t, err)

var parsed RootType
assert.NoError(t, parsed.UnmarshalJSON(data))
assert.True(t, parsed.Expires.Equal(truncated),
"round-tripped expires %v should equal %v", parsed.Expires, truncated)

// Re-marshaling the parsed value is stable and still second-precision.
data2, err := parsed.MarshalJSON()
assert.NoError(t, err)
assert.Equal(t, "2030-08-15T14:30:45Z", expiresFromJSON(t, data2))
}
23 changes: 19 additions & 4 deletions metadata/marshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,23 @@ import (
"errors"
"fmt"
"maps"
"time"
)

// expiresFormat is the TUF spec date/time layout for the "expires" field:
// an ISO 8601 / RFC 3339 timestamp in UTC truncated to whole seconds, e.g.
// "2030-01-01T00:00:00Z". A raw time.Time would otherwise be serialized by
// encoding/json with sub-second (RFC3339Nano) precision, which is not valid
// per the spec.
const expiresFormat = "2006-01-02T15:04:05Z"

// formatExpires renders an "expires" time in the spec-required second-precision
// UTC format. It is the single source of truth shared by the role MarshalJSON
// methods so they always agree.
func formatExpires(t time.Time) string {
return t.UTC().Format(expiresFormat)
}

// The following marshal/unmarshal methods override the default behavior for for each TUF type
// in order to support unrecognized fields

Expand All @@ -37,7 +52,7 @@ func (signed RootType) MarshalJSON() ([]byte, error) {
dict["spec_version"] = signed.SpecVersion
dict["consistent_snapshot"] = signed.ConsistentSnapshot
dict["version"] = signed.Version
dict["expires"] = signed.Expires
dict["expires"] = formatExpires(signed.Expires)
dict["keys"] = signed.Keys
dict["roles"] = signed.Roles
return json.Marshal(dict)
Expand Down Expand Up @@ -74,7 +89,7 @@ func (signed SnapshotType) MarshalJSON() ([]byte, error) {
dict["_type"] = signed.Type
dict["spec_version"] = signed.SpecVersion
dict["version"] = signed.Version
dict["expires"] = signed.Expires
dict["expires"] = formatExpires(signed.Expires)
dict["meta"] = signed.Meta
return json.Marshal(dict)
}
Expand Down Expand Up @@ -108,7 +123,7 @@ func (signed TimestampType) MarshalJSON() ([]byte, error) {
dict["_type"] = signed.Type
dict["spec_version"] = signed.SpecVersion
dict["version"] = signed.Version
dict["expires"] = signed.Expires
dict["expires"] = formatExpires(signed.Expires)
dict["meta"] = signed.Meta
return json.Marshal(dict)
}
Expand Down Expand Up @@ -142,7 +157,7 @@ func (signed TargetsType) MarshalJSON() ([]byte, error) {
dict["_type"] = signed.Type
dict["spec_version"] = signed.SpecVersion
dict["version"] = signed.Version
dict["expires"] = signed.Expires
dict["expires"] = formatExpires(signed.Expires)
dict["targets"] = signed.Targets
if signed.Delegations != nil {
dict["delegations"] = signed.Delegations
Expand Down
22 changes: 14 additions & 8 deletions metadata/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,25 +376,25 @@ func TestUnrecognizedFieldRolesSigned(t *testing.T) {
root.Signed.UnrecognizedFields = testUnrecognizedField
rootJSON, err := root.ToBytes(false)
assert.NoError(t, err)
assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"root\",\"consistent_snapshot\":true,\"expires\":\"2030-08-15T14:30:45.0000001Z\",\"keys\":{},\"roles\":{\"root\":{\"keyids\":[],\"threshold\":1},\"snapshot\":{\"keyids\":[],\"threshold\":1},\"targets\":{\"keyids\":[],\"threshold\":1},\"timestamp\":{\"keyids\":[],\"threshold\":1}},\"spec_version\":\"1.0.31\",\"test\":\"true\",\"version\":1}}"), rootJSON)
assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"root\",\"consistent_snapshot\":true,\"expires\":\"2030-08-15T14:30:45Z\",\"keys\":{},\"roles\":{\"root\":{\"keyids\":[],\"threshold\":1},\"snapshot\":{\"keyids\":[],\"threshold\":1},\"targets\":{\"keyids\":[],\"threshold\":1},\"timestamp\":{\"keyids\":[],\"threshold\":1}},\"spec_version\":\"1.0.31\",\"test\":\"true\",\"version\":1}}"), rootJSON)

targets := Targets(fixedExpire)
targets.Signed.UnrecognizedFields = testUnrecognizedField
targetsJSON, err := targets.ToBytes(false)
assert.NoError(t, err)
assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"targets\",\"expires\":\"2030-08-15T14:30:45.0000001Z\",\"spec_version\":\"1.0.31\",\"targets\":{},\"test\":\"true\",\"version\":1}}"), targetsJSON)
assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"targets\",\"expires\":\"2030-08-15T14:30:45Z\",\"spec_version\":\"1.0.31\",\"targets\":{},\"test\":\"true\",\"version\":1}}"), targetsJSON)

snapshot := Snapshot(fixedExpire)
snapshot.Signed.UnrecognizedFields = testUnrecognizedField
snapshotJSON, err := snapshot.ToBytes(false)
assert.NoError(t, err)
assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"snapshot\",\"expires\":\"2030-08-15T14:30:45.0000001Z\",\"meta\":{\"targets.json\":{\"version\":1}},\"spec_version\":\"1.0.31\",\"test\":\"true\",\"version\":1}}"), snapshotJSON)
assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"snapshot\",\"expires\":\"2030-08-15T14:30:45Z\",\"meta\":{\"targets.json\":{\"version\":1}},\"spec_version\":\"1.0.31\",\"test\":\"true\",\"version\":1}}"), snapshotJSON)

timestamp := Timestamp(fixedExpire)
timestamp.Signed.UnrecognizedFields = testUnrecognizedField
timestampJSON, err := timestamp.ToBytes(false)
assert.NoError(t, err)
assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"timestamp\",\"expires\":\"2030-08-15T14:30:45.0000001Z\",\"meta\":{\"snapshot.json\":{\"version\":1}},\"spec_version\":\"1.0.31\",\"test\":\"true\",\"version\":1}}"), timestampJSON)
assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"timestamp\",\"expires\":\"2030-08-15T14:30:45Z\",\"meta\":{\"snapshot.json\":{\"version\":1}},\"spec_version\":\"1.0.31\",\"test\":\"true\",\"version\":1}}"), timestampJSON)
}
func TestUnrecognizedFieldGenericMetadata(t *testing.T) {
// fixed expire
Expand All @@ -408,7 +408,7 @@ func TestUnrecognizedFieldGenericMetadata(t *testing.T) {
root.UnrecognizedFields = testUnrecognizedField
rootJSON, err := root.ToBytes(false)
assert.NoError(t, err)
assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"root\",\"consistent_snapshot\":true,\"expires\":\"2030-08-15T14:30:45.0000001Z\",\"keys\":{},\"roles\":{\"root\":{\"keyids\":[],\"threshold\":1},\"snapshot\":{\"keyids\":[],\"threshold\":1},\"targets\":{\"keyids\":[],\"threshold\":1},\"timestamp\":{\"keyids\":[],\"threshold\":1}},\"spec_version\":\"1.0.31\",\"version\":1},\"test\":\"true\"}"), rootJSON)
assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"root\",\"consistent_snapshot\":true,\"expires\":\"2030-08-15T14:30:45Z\",\"keys\":{},\"roles\":{\"root\":{\"keyids\":[],\"threshold\":1},\"snapshot\":{\"keyids\":[],\"threshold\":1},\"targets\":{\"keyids\":[],\"threshold\":1},\"timestamp\":{\"keyids\":[],\"threshold\":1}},\"spec_version\":\"1.0.31\",\"version\":1},\"test\":\"true\"}"), rootJSON)
}
func TestTargetFilesCustomField(t *testing.T) {
// custom JSON to test
Expand All @@ -426,7 +426,7 @@ func TestTargetFilesCustomField(t *testing.T) {
targets.Signed.Targets["testTarget"] = targetFile
targetsJSON, err := targets.ToBytes(false)
assert.NoError(t, err)
assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"targets\",\"expires\":\"2030-08-15T14:30:45.0000001Z\",\"spec_version\":\"1.0.31\",\"targets\":{\"testTarget\":{\"custom\":{\"test\":true},\"hashes\":{},\"length\":0}},\"version\":1}}"), targetsJSON)
assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"targets\",\"expires\":\"2030-08-15T14:30:45Z\",\"spec_version\":\"1.0.31\",\"targets\":{\"testTarget\":{\"custom\":{\"test\":true},\"hashes\":{},\"length\":0}},\"version\":1}}"), targetsJSON)
}

func TestFromBytes(t *testing.T) {
Expand Down Expand Up @@ -509,7 +509,10 @@ func TestToByte(t *testing.T) {
root.Signatures = append(root.Signatures, Signature{KeyID: "roothash", Signature: hash["ed25519"]})
rootBytes, err := root.ToBytes(false)
assert.NoError(t, err)
assert.Equal(t, string(testRootBytes), string(rootBytes))
// Even though the input expires string carries sub-second precision, the
// serialized output must use the spec-required whole-second UTC format.
expectedRootBytes := []byte("{\"signatures\":[{\"keyid\":\"roothash\",\"sig\":\"1307990e6ba5ca145eb35e99182a9bec46531bc54ddf656a602c780fa0240dee\"}],\"signed\":{\"_type\":\"root\",\"consistent_snapshot\":true,\"expires\":\"2030-08-15T14:30:45Z\",\"keys\":{\"roothash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubrootval\"},\"scheme\":\"ed25519\"},\"snapshothash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubsval\"},\"scheme\":\"ed25519\"},\"targetshash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubtrval\"},\"scheme\":\"ed25519\"},\"timestamphash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubtmval\"},\"scheme\":\"ed25519\"}},\"roles\":{\"root\":{\"keyids\":[\"roothash\"],\"threshold\":1},\"snapshot\":{\"keyids\":[\"snapshothash\"],\"threshold\":1},\"targets\":{\"keyids\":[\"targetshash\"],\"threshold\":1},\"timestamp\":{\"keyids\":[\"timestamphash\"],\"threshold\":1}},\"spec_version\":\"1.0.31\",\"version\":1}}")
assert.Equal(t, string(expectedRootBytes), string(rootBytes))
Comment on lines +514 to +515
}

func TestFromFile(t *testing.T) {
Expand Down Expand Up @@ -573,7 +576,10 @@ func TestToFile(t *testing.T) {
assert.FileExists(t, fileName)
data, err := os.ReadFile(fileName)
assert.NoError(t, err)
assert.Equal(t, string(testRootBytes), string(data))
// The input bytes carry a sub-second expires; the written output must use
// the spec-required whole-second UTC format.
expectedBytes := []byte("{\"signatures\":[{\"keyid\":\"roothash\",\"sig\":\"1307990e6ba5ca145eb35e99182a9bec46531bc54ddf656a602c780fa0240dee\"}],\"signed\":{\"_type\":\"root\",\"consistent_snapshot\":true,\"expires\":\"2030-08-15T14:30:45Z\",\"keys\":{\"roothash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubrootval\"},\"scheme\":\"ed25519\"},\"snapshothash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubsval\"},\"scheme\":\"ed25519\"},\"targetshash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubtrval\"},\"scheme\":\"ed25519\"},\"timestamphash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubtmval\"},\"scheme\":\"ed25519\"}},\"roles\":{\"root\":{\"keyids\":[\"roothash\"],\"threshold\":1},\"snapshot\":{\"keyids\":[\"snapshothash\"],\"threshold\":1},\"targets\":{\"keyids\":[\"targetshash\"],\"threshold\":1},\"timestamp\":{\"keyids\":[\"timestamphash\"],\"threshold\":1}},\"spec_version\":\"1.0.31\",\"version\":1}}")
assert.Equal(t, string(expectedBytes), string(data))
Comment on lines +581 to +582

err = os.RemoveAll(tmpDir)
assert.NoError(t, err)
Expand Down
Loading