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
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ require (
go.opentelemetry.io/otel/trace v1.43.0
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/crypto v0.52.0
golang.org/x/mod v0.37.0
golang.org/x/net v0.55.0
golang.org/x/sync v0.20.0
golang.org/x/text v0.37.0
Expand Down Expand Up @@ -84,9 +85,8 @@ require (
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,8 @@ golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGb
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
Expand Down Expand Up @@ -326,8 +326,8 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200313205530-4303120df7d8/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
Expand Down
140 changes: 140 additions & 0 deletions trees/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package checkpoint

import (
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"unicode/utf8"

"golang.org/x/mod/sumdb/note"
"golang.org/x/mod/sumdb/tlog"
)

// Checkpoint represents a tlog-checkpoint note body.
//
// https://c2sp.org/tlog-checkpoint
type Checkpoint struct {
Origin string
Tree tlog.Tree
Extensions []string
}

// String returns the note body of the checkpoint. Unlike Marshal, it does not
// validate the checkpoint fields. Call Marshal to validate the fields before
// serializing.
//
// https://c2sp.org/tlog-checkpoint
func (c Checkpoint) String() string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Checkpoint type contains both a slice (Extensions) and a potentially-large array (Tree.Hash), so we should probably not be passing Checkpoints around by value. These should be pointer receivers, and Parse should return a *Checkpoint.

var b strings.Builder
fmt.Fprintf(&b, "%s\n%d\n%s\n", c.Origin, c.Tree.N, c.Tree.Hash)
for _, ext := range c.Extensions {
b.WriteString(ext)
b.WriteByte('\n')
}
return b.String()
}

// validNoteLine reports whether s is a valid signed-note line: UTF-8 with no
// control character below U+0020.
func validNoteLine(s string) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are checkpoints the only kind of signed note we're going to be producing or parsing? If not, then this and other helpers should probably be in a separate note package.

if !utf8.ValidString(s) {
return false
}
for _, r := range s {
if r < 0x20 {
return false
}
}
return true
}

// Marshal returns the note body, first checking the checkpoint against the
// tlog-checkpoint rules. It returns an error if the checkpoint is invalid.
//
// - https://c2sp.org/tlog-checkpoint
// - https://c2sp.org/signed-note
func (c Checkpoint) Marshal() (string, error) {
if c.Origin == "" {
return "", errors.New("empty checkpoint origin")
}
if !validNoteLine(c.Origin) {
return "", errors.New("checkpoint origin contains a control character or invalid UTF-8")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There'd be no need for this verbose "or" error line if validNoteLine returned a more-specific error itself. It would also allow you to avoid repeating this same error message several times below.

}
if c.Tree.N < 0 {
return "", fmt.Errorf("negative checkpoint tree size %d", c.Tree.N)
}
for _, ext := range c.Extensions {
if ext == "" {
return "", errors.New("empty checkpoint extension line")
}
if !validNoteLine(ext) {
return "", errors.New("checkpoint extension line contains a control character or invalid UTF-8")
}
}
return c.String(), nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I love this relationship between String() and Marshal(). I like having both methods! But:
a) I think that most callers of .String() (e.g. test got/want error output) expect the result of that method to be single-line; and
b) Having two ways to get the exact same result, one of which is unsafe, feels unsafe to me.

How would you feel about having .String() instead return a purposefully non-compliant single-line result, specifically for internal debugging use, while .Marshal() constructs the actual format?

}

// Parse parses a checkpoint note body. It must not have any signature lines.
// For a signed note, use Open.
//
// - https://c2sp.org/tlog-checkpoint
// - https://c2sp.org/signed-note
func Parse(text string) (Checkpoint, error) {
if !strings.HasSuffix(text, "\n") {
return Checkpoint{}, errors.New("checkpoint does not end in newline")
}
lines := strings.Split(strings.TrimSuffix(text, "\n"), "\n")
if len(lines) < 3 {
return Checkpoint{}, errors.New("checkpoint has too few lines")
}

origin := lines[0]
if origin == "" {
return Checkpoint{}, errors.New("empty checkpoint origin")
}
if !validNoteLine(origin) {
return Checkpoint{}, errors.New("checkpoint origin contains a control character or invalid UTF-8")
}

size, err := strconv.ParseInt(lines[1], 10, 64)
if err != nil || size < 0 || strconv.FormatInt(size, 10) != lines[1] {
return Checkpoint{}, errors.New("malformed checkpoint tree size")
}

hashBytes, err := base64.StdEncoding.DecodeString(lines[2])
if err != nil || len(hashBytes) != tlog.HashSize || base64.StdEncoding.EncodeToString(hashBytes) != lines[2] {
return Checkpoint{}, errors.New("malformed checkpoint root hash")
}
var hash tlog.Hash
copy(hash[:], hashBytes)

extensions := lines[3:]
for _, ext := range extensions {
if ext == "" {
return Checkpoint{}, errors.New("empty checkpoint extension line")
}
if !validNoteLine(ext) {
return Checkpoint{}, errors.New("checkpoint extension line contains a control character or invalid UTF-8")
}
}
return Checkpoint{Origin: origin, Tree: tlog.Tree{N: size, Hash: hash}, Extensions: extensions}, nil
}

// Open opens a signed checkpoint note and parses its body. An error is returned
// if the note is not valid or the signature is not verified by one of the
// verifiers.
//
// https://c2sp.org/signed-note
func Open(signedNote []byte, verifiers note.Verifiers) (Checkpoint, *note.Note, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Who are the prospective callers of this function? Why do they want access to the underlying Note, and not just the Checkpoint? They're just different in-memory views onto the same text, right?

n, err := note.Open(signedNote, verifiers)
if err != nil {
return Checkpoint{}, nil, err
}
c, err := Parse(n.Text)
if err != nil {
return Checkpoint{}, nil, err
}
return c, n, nil
}
Loading