Skip to content
Merged
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
209 changes: 209 additions & 0 deletions versions/upstream/check_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package upstream

import (
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/spin-stack/go-tools/versions"
)

// gitRepo is a repository with a lightweight tag v1.0.0 on its first commit, an annotated tag
// v1.2.0 and a branch topic on its second, and master one commit past them.
type gitRepo struct {
dir, first, second, head string
}

func newGitRepo(t *testing.T) gitRepo {
t.Helper()
r := gitRepo{dir: t.TempDir()}
git := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", r.dir, "-c", "user.name=t", "-c", "user.email=t@t"}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
git("init", "-q", "-b", "master")
git("commit", "-q", "--allow-empty", "-m", "one")
r.first = git("rev-parse", "HEAD")
git("tag", "v1.0.0")
git("commit", "-q", "--allow-empty", "-m", "two")
r.second = git("rev-parse", "HEAD")
git("tag", "-a", "-m", "annotated", "v1.2.0")
git("branch", "topic")
git("commit", "-q", "--allow-empty", "-m", "three")
r.head = git("rev-parse", "HEAD")
return r
}

// A git pin is a commit: an annotated tag's is the commit it points at, not the tag's own object,
// and a branch's is its head.
func TestAGitPinIsTheCommit(t *testing.T) {
r := newGitRepo(t)
e := versions.Entry{Name: "x", Kind: versions.Git, Source: r.dir}
for version, want := range map[string]string{"v1.0.0": r.first, "v1.2.0": r.second, "topic": r.second, "master": r.head} {
if got, err := resolve(t.Context(), e, version); err != nil || got != want {
t.Errorf("%s: pin %q, %v; want %q", version, got, err, want)
}
}
if got, err := resolve(t.Context(), e, "v9"); err == nil {
t.Errorf("a version the repository does not have was pinned at %q", got)
}
e.Source = filepath.Join(r.dir, "nothing")
if got, err := resolve(t.Context(), e, "master"); err == nil {
t.Errorf("a repository that is not there answered %q", got)
}
}

// A download's pin is the SHA-256 of the file at the version asked, not the one pinned.
func TestADownloadsPinIsTheSumAtTheVersionAsked(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/2/f" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte("two"))
}))
t.Cleanup(srv.Close)
e := versions.Entry{Name: "f", Kind: versions.Download, Source: srv.URL + "/{version}/f", Version: "1"}
sum := sha256.Sum256([]byte("two"))
if got, err := resolve(t.Context(), e, "2"); err != nil || got != hex.EncodeToString(sum[:]) {
t.Errorf("pin %q, %v", got, err)
}
}

// docker is a stand-in for the docker CLI that prints the digest in digestFile, or fails when
// there is none.
func docker(t *testing.T) (digestFile string) {
t.Helper()
dir := t.TempDir()
digestFile = filepath.Join(dir, "digest")
script := "#!/bin/sh\nexec cat " + digestFile + "\n"
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { //nolint:gosec // an executable is the point
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return digestFile
}

// An image's pin is the digest its tag names now, as docker reads it.
func TestAnImagesPinIsItsDigest(t *testing.T) {
digestFile := docker(t)
e := versions.Entry{Name: "go", Kind: versions.Image, Source: "golang"}
if got, err := resolve(t.Context(), e, "1.27"); err == nil {
t.Errorf("docker failed and the pin is %q", got)
}
if err := os.WriteFile(digestFile, []byte("sha256:abc\n"), 0o600); err != nil {
t.Fatal(err)
}
if got, err := resolve(t.Context(), e, "1.27"); err != nil || got != "sha256:abc" {
t.Errorf("pin %q, %v", got, err)
}
}

// Each track finds the newest its own way; these are the ones that need no network.
func TestTheNewestIsWhatTheTrackFinds(t *testing.T) {
r := newGitRepo(t)
for _, tc := range []struct {
name string
entry versions.Entry
want string // "" is an error
}{
{"a git entry by its tags", versions.Entry{Kind: versions.Git, Version: "v1.0.0", Track: "tags " + r.dir}, "v1.2.0"},
{"a tags track whose repository is not there", versions.Entry{Kind: versions.Git, Version: "v1.0.0", Track: "tags " + filepath.Join(r.dir, "nothing")}, ""},
{"a branch is its own newest", versions.Entry{Version: "master", Track: "branch"}, "master"},
{"a commit is its branch's head", versions.Entry{Track: "commit " + r.dir + " master"}, r.head},
// Neither is GitHub, so the error says which repository was asked.
{"a release of the repository the track names", versions.Entry{Source: "https://example.com/a", Track: "github-release https://example.com/b"}, ""},
{"a PyPI package whose name is no URL", versions.Entry{Source: "a\x7f", Track: "pypi"}, ""},
{"a track check does not know", versions.Entry{Track: "rumour"}, ""},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := newest(t.Context(), tc.entry)
if tc.want == "" && err == nil {
t.Fatalf("newest %q, want an error", got)
}
if tc.want != "" && (err != nil || got != tc.want) {
t.Errorf("newest %q, %v; want %q", got, err, tc.want)
}
})
}
_, err := newest(t.Context(), versions.Entry{Source: "https://example.com/a", Track: "github-release https://example.com/b"})
if !strings.Contains(err.Error(), "https://example.com/b") {
t.Errorf("the track's repository is not the one asked: %v", err)
}
}

// Check says where each entry stands: at its newest, behind it, behind at the same version because
// the name points elsewhere now, or a note saying why it cannot tell.
func TestCheckSaysWhereEachEntryStands(t *testing.T) {
r := newGitRepo(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("v1.2.0's file"))
}))
t.Cleanup(srv.Close)
sum := sha256.Sum256([]byte("v1.2.0's file"))
missing := httptest.NewServer(http.NotFoundHandler())
t.Cleanup(missing.Close)
digestFile := docker(t)
if err := os.WriteFile(digestFile, []byte("sha256:new\n"), 0o600); err != nil {
t.Fatal(err)
}

git := versions.Entry{Name: "g", Kind: versions.Git, Source: r.dir, Version: "master", Track: "branch"}
moved, gone := git, git
moved.Name, moved.Pin = "moved", r.second
gone.Name, gone.Source = "gone", filepath.Join(r.dir, "nothing")
git.Pin = r.head
image := versions.Entry{Name: "i", Kind: versions.Image, Source: "golang", Version: "1.27", Pin: "sha256:old", Track: "digest"}
// Behind by its version, and its file at that version is the same bytes: still behind, since
// only a file tracked by a commit is excused.
download := versions.Entry{Name: "d", Kind: versions.Download, Source: srv.URL + "/{version}", Version: "v1.0.0",
Pin: hex.EncodeToString(sum[:]), Track: "tags " + r.dir}
current := download
current.Name, current.Version, current.Pin = "current", "v1.2.0", strings.Repeat("0", 64)
// A file tracked by a commit is at its branch's head, behind it with the same bytes, or behind
// it where the file cannot be read; and a git entry tracked by a commit is not a file.
atHead := versions.Entry{Name: "at-head", Kind: versions.Download, Source: srv.URL + "/{version}", Version: r.head,
Pin: hex.EncodeToString(sum[:]), Track: "commit " + r.dir + " master"}
unread := atHead
unread.Name, unread.Version, unread.Source = "unread", r.first, missing.URL+"/{version}"
gitByCommit := versions.Entry{Name: "git-by-commit", Kind: versions.Git, Source: r.dir, Version: r.first, Pin: r.head,
Track: "commit " + r.dir + " master"}
follows := versions.Entry{Name: "f", Track: "follows spin", Note: "spin says"}
unknown := versions.Entry{Name: "u", Track: "rumour"}

got := Check(t.Context(), &versions.Versions{Entries: []versions.Entry{git, moved, gone, image, download, current, atHead, unread, gitByCommit, follows, unknown}})
want := []struct {
newest string
behind bool
note string
}{
{"master", false, ""},
{"master", true, "master is now " + r.head},
{"master", false, "ls-remote"},
{"1.27", true, "1.27 is now sha256:new"},
{"v1.2.0", true, ""},
{"v1.2.0", false, ""},
{r.head, false, ""},
{r.head, true, "404"},
{r.head, true, ""},
{"", false, "follows spin: spin says"},
{"", false, "which check does not know"},
}
for i, w := range want {
st := got[i]
if st.Newest != w.newest || st.Behind != w.behind || !strings.Contains(st.Note, w.note) || (w.note == "" && st.Note != "") {
t.Errorf("%s: %+v, want newest %q behind %v note %q", st.Entry.Name, st, w.newest, w.behind, w.note)
}
}
}
24 changes: 12 additions & 12 deletions versions/upstream/upstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ import (
// errFollows is an entry whose newest version is another's to say: a person reads it there.
var errFollows = errors.New("versions: follows another")

// Newest is the version check says e could be at: the same for an image tracked by its digest,
// newest is the version check says e could be at: the same for an image tracked by its digest,
// or a branch, whose pin is what moves.
func Newest(ctx context.Context, e versions.Entry) (string, error) {
func newest(ctx context.Context, e versions.Entry) (string, error) {
kind, arg, _ := strings.Cut(e.Track, " ")
switch kind {
case "github-release":
Expand Down Expand Up @@ -82,9 +82,9 @@ func Newest(ctx context.Context, e versions.Entry) (string, error) {
return "", fmt.Errorf("versions: %s tracks %q, which check does not know", e.Name, e.Track)
}

// Resolve is the pin of e at version: an image's digest, a tag's or a branch's commit, a
// resolve is the pin of e at version: an image's digest, a tag's or a branch's commit, a
// download's sha256.
func Resolve(ctx context.Context, e versions.Entry, version string) (string, error) {
func resolve(ctx context.Context, e versions.Entry, version string) (string, error) {
e.Version = version
switch e.Kind {
case versions.Image:
Expand Down Expand Up @@ -132,11 +132,11 @@ func Bump(ctx context.Context, v *versions.Versions, name, version string) (vers
return versions.Entry{}, nil, fmt.Errorf("versions: %s's archive is what a build's git writes of the commit, so it is bumped by hand: see its note", name)
}
if version == "" {
if version, err = Newest(ctx, e); err != nil {
if version, err = newest(ctx, e); err != nil {
return versions.Entry{}, nil, fmt.Errorf("%w; name the version", err)
}
}
pin, err := Resolve(ctx, e, version)
pin, err := resolve(ctx, e, version)
if err != nil {
return versions.Entry{}, nil, err
}
Expand All @@ -149,7 +149,7 @@ func Bump(ctx context.Context, v *versions.Versions, name, version string) (vers
}

// Pinned is e's download, refused past limit bytes, and unless its SHA-256 is e's pin: the check
// beside the Resolve that wrote the pin.
// beside the resolve that wrote the pin.
func Pinned(ctx context.Context, e versions.Entry, limit int64) ([]byte, error) {
raw, err := fetch.Bytes(ctx, e.URL(), limit)
if err != nil {
Expand Down Expand Up @@ -398,19 +398,19 @@ func Check(ctx context.Context, v *versions.Versions) []Status {
var out []Status
for _, e := range v.Entries {
st := Status{Entry: e}
newest, err := Newest(ctx, e)
latest, err := newest(ctx, e)
switch {
case errors.Is(err, errFollows):
st.Note = "follows " + strings.TrimPrefix(e.Track, "follows ") + ": " + e.Note
case err != nil:
st.Note = err.Error()
default:
st.Newest = newest
st.Behind = newest != e.Version
st.Newest = latest
st.Behind = latest != e.Version
// A file pinned to a commit of a busy branch: the branch moves several times a day and
// the file almost never. Behind is the file changing, not the commit.
if st.Behind && e.Kind == versions.Download && strings.HasPrefix(e.Track, "commit ") {
pin, err := Resolve(ctx, e, newest)
pin, err := resolve(ctx, e, latest)
switch {
case err != nil:
st.Note = err.Error()
Expand All @@ -419,7 +419,7 @@ func Check(ctx context.Context, v *versions.Versions) []Status {
}
}
if !st.Behind && (e.Kind == versions.Image || e.Kind == versions.Git) {
pin, err := Resolve(ctx, e, newest)
pin, err := resolve(ctx, e, latest)
if err != nil {
st.Note = err.Error()
} else if pin != e.Pin {
Expand Down
8 changes: 4 additions & 4 deletions versions/upstream/upstream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ func TestTheNewestIsWhatThePageLinksTo(t *testing.T) {
}))
t.Cleanup(srv.Close)
e := versions.Entry{Name: "icons", Source: "https://cdn/{version}/Icon-package_{version}.zip", Track: "page " + srv.URL}
if got, err := Newest(t.Context(), e); err != nil || got != "07312026.abc" {
t.Errorf("Newest = %q, %v", got, err)
if got, err := newest(t.Context(), e); err != nil || got != "07312026.abc" {
t.Errorf("newest = %q, %v", got, err)
}
e.Source = "https://cdn/Other_{version}.zip"
if _, err := Newest(t.Context(), e); err == nil {
if _, err := newest(t.Context(), e); err == nil {
t.Error("a page that links to no such file said a version")
}
}
Expand Down Expand Up @@ -142,7 +142,7 @@ func TestACommitTrackedFileIsBehindOnlyWhenItChanged(t *testing.T) {
defer srv.Close()
e := versions.Entry{Name: "check-config", Kind: versions.Download, Source: srv.URL + "/{version}/check-config.sh",
Version: first, Track: "commit " + repo + " master"}
pin, err := Resolve(t.Context(), e, first)
pin, err := resolve(t.Context(), e, first)
if err != nil {
t.Fatal(err)
}
Expand Down
10 changes: 5 additions & 5 deletions versions/versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ var (
sum = regexp.MustCompile(`^[0-9a-f]{64}$`)
)

// Validate is the entry whole and its pin in its kind's form.
func (e Entry) Validate() error {
// validate is the entry whole and its pin in its kind's form.
func (e Entry) validate() error {
var errs []error
check := func(ok bool, format string, args ...any) {
if !ok {
Expand Down Expand Up @@ -169,7 +169,7 @@ func Parse(raw []byte) (*Versions, error) {
errs = append(errs, fmt.Errorf("versions: %s is there twice", e.Name))
}
seen[e.Name] = true
errs = append(errs, e.Validate())
errs = append(errs, e.validate())
}
if err := errors.Join(errs...); err != nil {
return nil, err
Expand Down Expand Up @@ -197,7 +197,7 @@ func (v *Versions) Set(name, version, pin string) ([]byte, error) {
return nil, err
}
e.Version, e.Pin = version, pin
if err := e.Validate(); err != nil {
if err := e.validate(); err != nil {
return nil, err
}
lines := strings.SplitAfter(string(v.raw), "\n")
Expand All @@ -210,7 +210,7 @@ func (v *Versions) Set(name, version, pin string) ([]byte, error) {
if n := field(item, "name"); n == nil || n.Value != name {
continue
}
for _, kv := range [][2]string{{"version", version}, {"pin", pin}} {
for _, kv := range [][2]string{{"version", version}, {"pin", pin}} { // mutate-exempt: the pair's length; nothing reads past kv[1]
n := field(item, kv[0])
if n == nil {
continue
Expand Down
23 changes: 23 additions & 0 deletions versions/versions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,26 @@ func TestABumpKeepsHowTheValueIsWritten(t *testing.T) {
})
}
}

// An entry is refused when a field is not in its kind's form, each for its own reason: a download
// has an https source with {version} in it, and a module a package path, not a URL.
func TestAnEntryOutOfItsKindsFormIsRefused(t *testing.T) {
for _, tc := range []struct{ name, entry, want string }{
{"a download over http", "kind: download\n source: http://x/{version}.tgz\n pin: " + strings.Repeat("a", 64),
"a download whose source is not an https URL"},
{"a download with no version in its source", "kind: download\n source: https://x/latest.tgz\n pin: " + strings.Repeat("a", 64),
"a download whose source is not an https URL"},
{"a module named by a URL", "kind: module\n source: https://x/y", "a module with no package path"},
{"a module with no source", "kind: module", "a module with no package path"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := Parse([]byte("- name: a\n " + tc.entry + "\n version: v1\n track: tags x\n"))
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("err = %v, want %q", err, tc.want)
}
})
}
if _, err := Parse([]byte("- name: a\n kind: module\n source: example.com/x\n version: v1\n track: tags x\n")); err != nil {
t.Errorf("a module by its package path: %v", err)
}
}