Skip to content

Commit cf71498

Browse files
trees/checkpoint: Add checkpoint.Checkpoint (#8830)
Add `Checkpoint` to represent a [tlog-checkpoint](https://c2sp.org/tlog-checkpoint) note body, with `.Marshal()` to serialize with field validation. Add `Unmarshal()` to parse an unsigned body and `Open()` to verify a signed note and parse its body into a `Checkpoint`.
1 parent f876e77 commit cf71498

15 files changed

Lines changed: 2677 additions & 251 deletions

File tree

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ require (
3838
go.opentelemetry.io/otel/trace v1.43.0
3939
go.yaml.in/yaml/v3 v3.0.4
4040
golang.org/x/crypto v0.52.0
41+
golang.org/x/mod v0.37.0
4142
golang.org/x/net v0.55.0
4243
golang.org/x/sync v0.20.0
4344
golang.org/x/term v0.43.0
@@ -85,9 +86,8 @@ require (
8586
go.opentelemetry.io/otel/metric v1.43.0 // indirect
8687
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
8788
go.uber.org/atomic v1.11.0 // indirect
88-
golang.org/x/mod v0.35.0 // indirect
8989
golang.org/x/sys v0.45.0 // indirect
90-
golang.org/x/tools v0.44.0 // indirect
90+
golang.org/x/tools v0.45.0 // indirect
9191
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
9292
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
9393
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,8 @@ golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGb
284284
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
285285
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
286286
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
287-
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
288-
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
287+
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
288+
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
289289
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
290290
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
291291
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -328,8 +328,8 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
328328
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
329329
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
330330
golang.org/x/tools v0.0.0-20200313205530-4303120df7d8/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
331-
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
332-
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
331+
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
332+
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
333333
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
334334
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
335335
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

trees/checkpoint/checkpoint.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package checkpoint
2+
3+
import (
4+
"encoding/base64"
5+
"errors"
6+
"fmt"
7+
"strconv"
8+
"strings"
9+
"unicode/utf8"
10+
11+
"golang.org/x/mod/sumdb/note"
12+
"golang.org/x/mod/sumdb/tlog"
13+
)
14+
15+
// Checkpoint represents a tlog-checkpoint note text.
16+
//
17+
// https://c2sp.org/tlog-checkpoint
18+
type Checkpoint struct {
19+
Origin string
20+
Tree tlog.Tree
21+
Extensions []string
22+
}
23+
24+
// checkNoteText returns an error if text cannot be a signed note's text, nil
25+
// otherwise. https://c2sp.org/signed-note requires note text to be valid UTF-8
26+
// with no ASCII control characters (those below U+0020) other than newline.
27+
func checkNoteText(text string) error {
28+
switch {
29+
case !utf8.ValidString(text):
30+
return errors.New("not valid UTF-8")
31+
32+
case strings.ContainsFunc(text, func(r rune) bool { return r < 0x20 && r != '\n' }):
33+
return errors.New("contains an ASCII control character other than newline")
34+
35+
default:
36+
return nil
37+
}
38+
}
39+
40+
// checkNoteField returns an error if field contains a newline, the note's line
41+
// delimiter, nil otherwise.
42+
func checkNoteField(field string) error {
43+
if strings.ContainsRune(field, '\n') {
44+
return errors.New("contains a newline")
45+
}
46+
return nil
47+
}
48+
49+
// validate returns an error if the checkpoint's fields violate the
50+
// tlog-checkpoint requirements, nil otherwise.
51+
func (c *Checkpoint) validate() error {
52+
if c.Origin == "" {
53+
return errors.New("empty checkpoint origin")
54+
}
55+
err := checkNoteField(c.Origin)
56+
if err != nil {
57+
return fmt.Errorf("validating checkpoint origin: %w", err)
58+
}
59+
if c.Tree.N < 0 {
60+
return fmt.Errorf("negative checkpoint tree size %d", c.Tree.N)
61+
}
62+
for _, ext := range c.Extensions {
63+
if ext == "" {
64+
return errors.New("empty checkpoint extension line")
65+
}
66+
err := checkNoteField(ext)
67+
if err != nil {
68+
return fmt.Errorf("validating checkpoint extension line: %w", err)
69+
}
70+
}
71+
return nil
72+
}
73+
74+
// Marshal returns the note text, first checking the checkpoint against the
75+
// tlog-checkpoint requirements. It returns an error if the checkpoint is
76+
// invalid.
77+
//
78+
// - https://c2sp.org/tlog-checkpoint
79+
// - https://c2sp.org/signed-note
80+
func (c *Checkpoint) Marshal() (string, error) {
81+
err := c.validate()
82+
if err != nil {
83+
return "", err
84+
}
85+
86+
var b strings.Builder
87+
fmt.Fprintf(&b, "%s\n%d\n%s\n", c.Origin, c.Tree.N, c.Tree.Hash)
88+
for _, ext := range c.Extensions {
89+
b.WriteString(ext)
90+
b.WriteByte('\n')
91+
}
92+
text := b.String()
93+
err = checkNoteText(text)
94+
if err != nil {
95+
return "", fmt.Errorf("validating checkpoint note text: %w", err)
96+
}
97+
return text, nil
98+
}
99+
100+
// Unmarshal parses a checkpoint note text. The text must not have any signature
101+
// lines. For a signed note, use Open.
102+
//
103+
// - https://c2sp.org/tlog-checkpoint
104+
// - https://c2sp.org/signed-note
105+
func Unmarshal(text string) (*Checkpoint, error) {
106+
err := checkNoteText(text)
107+
if err != nil {
108+
return nil, fmt.Errorf("validating checkpoint note text: %w", err)
109+
}
110+
if !strings.HasSuffix(text, "\n") {
111+
return nil, errors.New("checkpoint does not end in newline")
112+
}
113+
lines := strings.Split(strings.TrimSuffix(text, "\n"), "\n")
114+
if len(lines) < 3 {
115+
return nil, fmt.Errorf("checkpoint has %d lines, want at least 3", len(lines))
116+
}
117+
118+
size, err := strconv.ParseInt(lines[1], 10, 64)
119+
if err != nil {
120+
return nil, fmt.Errorf("checkpoint tree size: %w", err)
121+
}
122+
if size < 0 {
123+
return nil, fmt.Errorf("negative checkpoint tree size %d", size)
124+
}
125+
if strconv.FormatInt(size, 10) != lines[1] {
126+
return nil, errors.New("checkpoint tree size has a leading zero or sign")
127+
}
128+
129+
hashBytes, err := base64.StdEncoding.DecodeString(lines[2])
130+
if err != nil {
131+
return nil, fmt.Errorf("checkpoint root hash: %w", err)
132+
}
133+
if len(hashBytes) != tlog.HashSize {
134+
return nil, fmt.Errorf("checkpoint root hash is %d bytes, want %d", len(hashBytes), tlog.HashSize)
135+
}
136+
if base64.StdEncoding.EncodeToString(hashBytes) != lines[2] {
137+
return nil, errors.New("checkpoint root hash is not canonical base64")
138+
}
139+
var hash tlog.Hash
140+
copy(hash[:], hashBytes)
141+
142+
c := &Checkpoint{Origin: lines[0], Tree: tlog.Tree{N: size, Hash: hash}, Extensions: lines[3:]}
143+
return c, c.validate()
144+
}
145+
146+
// Open opens a signed checkpoint note and parses its text. An error is returned
147+
// if signedNote is not a well-formed note, if any of the verifiers rejects a
148+
// signature (note.InvalidSignatureError), if none of the verifiers has signed
149+
// the note (note.UnverifiedNoteError), or if the note's text is not a
150+
// well-formed checkpoint. Signatures from unknown keys are ignored.
151+
//
152+
// - https://c2sp.org/tlog-checkpoint
153+
// - https://c2sp.org/signed-note
154+
func Open(signedNote []byte, verifiers note.Verifiers) (*Checkpoint, *note.Note, error) {
155+
n, err := note.Open(signedNote, verifiers)
156+
if err != nil {
157+
return nil, nil, err
158+
}
159+
c, err := Unmarshal(n.Text)
160+
if err != nil {
161+
return nil, nil, err
162+
}
163+
return c, n, nil
164+
}

0 commit comments

Comments
 (0)