notary/internal/notecheck: check go.sum against notary

This is part of a design sketch for a Go module notary.
Eventually the code will live outside golang.org/x/exp.

Everything here is subject to change! Don't depend on it!

This is a demo of using the notary URL endpoints
to validate go.sum entries. The real implementation
in cmd/go would cache both the tree head and
downloaded tiles.

Change-Id: I6715cbe40e07fbab0c5cb9ab20e366195b66e6da
Reviewed-on: https://go-review.googlesource.com/c/exp/+/162899
Run-TryBot: Russ Cox <rsc@golang.org>
Reviewed-by: Filippo Valsorda <filippo@golang.org>
diff --git a/notary/internal/notecheck/main.go b/notary/internal/notecheck/main.go
new file mode 100644
index 0000000..1c933aa
--- /dev/null
+++ b/notary/internal/notecheck/main.go
@@ -0,0 +1,260 @@
+// Copyright 2019 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Notecheck checks a go.sum file against a notary.
+//
+// WARNING! This program is meant as a proof of concept demo and
+// should not be used in production scripts.
+// It does not set an exit status to report whether the
+// checksums matched, and it does not filter the go.sum
+// according to the $GONOVERIFY environment variable.
+//
+// Usage:
+//
+//	notecheck [-v] notary-key go.sum
+//
+// The -v flag enables verbose output.
+//
+package main
+
+import (
+	"bytes"
+	"encoding/hex"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"net/http"
+	"os"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"golang.org/x/exp/notary/internal/note"
+	"golang.org/x/exp/notary/internal/tlog"
+)
+
+func usage() {
+	fmt.Fprintf(os.Stderr, "usage: notecheck [-u url] [-h H] [-v] notary-key go.sum...\n")
+	os.Exit(2)
+}
+
+var height = flag.Int("h", 2, "tile height")
+var vflag = flag.Bool("v", false, "enable verbose output")
+var url = flag.String("u", "", "url to notary (overriding name)")
+
+func main() {
+	log.SetPrefix("notecheck: ")
+	log.SetFlags(0)
+
+	flag.Usage = usage
+	flag.Parse()
+	if flag.NArg() < 2 {
+		usage()
+	}
+
+	vkey := flag.Arg(0)
+	verifier, err := note.NewVerifier(vkey)
+	if err != nil {
+		log.Fatal(err)
+	}
+	if *url == "" {
+		*url = "https://" + verifier.Name()
+	}
+	msg, err := httpGet(*url + "/latest")
+	if err != nil {
+		log.Fatal(err)
+	}
+	treeNote, err := note.Open(msg, note.NotaryList(verifier))
+	if err != nil {
+		log.Fatalf("reading note: %v\nnote:\n%s", err, msg)
+	}
+	tree, err := tlog.ParseTree([]byte(treeNote.Text))
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	if *vflag {
+		log.Printf("validating against %s @%d", verifier.Name(), tree.N)
+	}
+
+	verifierURL := *url
+	tr := &tileReader{url: verifierURL + "/"}
+	thr := tlog.TileHashReader(tree, tr)
+	if _, err := tlog.TreeHash(tree.N, thr); err != nil {
+		log.Fatal(err)
+	}
+
+	for _, arg := range flag.Args()[1:] {
+		data, err := ioutil.ReadFile(arg)
+		if err != nil {
+			log.Fatal(err)
+		}
+		log.SetPrefix("notecheck: " + arg + ": ")
+		checkGoSum(data, verifierURL, thr)
+		log.SetPrefix("notecheck: ")
+	}
+}
+
+func checkGoSum(data []byte, verifierURL string, thr tlog.HashReader) {
+	lines := strings.SplitAfter(string(data), "\n")
+	if lines[len(lines)-1] != "" {
+		log.Printf("error: final line missing newline")
+		return
+	}
+	lines = lines[:len(lines)-1]
+	if len(lines)%2 != 0 {
+		log.Printf("error: odd number of lines")
+	}
+	for i := 0; i+2 <= len(lines); i += 2 {
+		f1 := strings.Fields(lines[i])
+		f2 := strings.Fields(lines[i+1])
+		if len(f1) != 3 || len(f2) != 3 || f1[0] != f2[0] || f1[1]+"/go.mod" != f2[1] {
+			log.Printf("error: bad line pair:\n\t%s\t%s", lines[i], lines[i+1])
+			continue
+		}
+
+		data, err := httpGet(verifierURL + "/lookup/" + f1[0] + "@" + f1[1])
+		if err != nil {
+			log.Printf("%s@%s: %v", f1[0], f1[1], err)
+			continue
+		}
+		j := bytes.IndexByte(data, '\n')
+		if j < 0 {
+			log.Printf("%s@%s: short response from lookup", f1[0], f1[1])
+			continue
+		}
+		id, err := strconv.ParseInt(strings.TrimSpace(string(data[:j])), 10, 64)
+		if err != nil {
+			log.Printf("%s@%s: unexpected response:\n%s", f1[0], f1[1], data)
+			continue
+		}
+		ldata := data[j+1:]
+
+		c := make(chan *tlog.Hash, 1)
+		go func() {
+			hashes, err := thr.ReadHashes([]int64{tlog.StoredHashIndex(0, id)})
+			if err != nil {
+				log.Printf("%s@%s: %v", f1[0], f1[1], err)
+				c <- nil
+				return
+			}
+			c <- &hashes[0]
+		}()
+
+		// The record lookup can be skipped in favor of using the /lookup response
+		// but we fetch record and test that they match, to check the server.
+		data, err = httpGet(verifierURL + "/record/" + fmt.Sprint(id))
+		if err != nil {
+			log.Printf("%s@%s: %v", f1[0], f1[1], err)
+			continue
+		}
+		if !bytes.Equal(data, ldata) {
+			log.Printf("%s@%s: different data from lookup and record:\n%s\n%s", hex.Dump(ldata), hex.Dump(data))
+			continue
+		}
+
+		hash := tlog.RecordHash(data)
+		hash1 := <-c
+		if hash1 == nil {
+			continue
+		}
+		if *hash1 != hash {
+			log.Printf("%s@%s: inconsistent records on notary!", f1[0], f1[1])
+			continue
+		}
+		if string(data) != lines[i]+lines[i+1] {
+			log.Printf("%s@%s: invalid go.sum entries:\nhave:\n\t%s\t%swant:\n\t%s", f1[0], f1[1], lines[i], lines[i+1], strings.Replace(string(data), "\n", "\n\t", -1))
+		}
+	}
+}
+
+func init() {
+	http.DefaultClient.Timeout = 10 * time.Second
+}
+
+func httpGet(url string) ([]byte, error) {
+	start := time.Now()
+	resp, err := http.Get(url)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != 200 {
+		return nil, fmt.Errorf("GET %v: %v", url, resp.Status)
+	}
+	data, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+	if *vflag {
+		fmt.Fprintf(os.Stderr, "%.3fs %s\n", time.Since(start).Seconds(), url)
+	}
+	return data, nil
+}
+
+type tileReader struct {
+	url     string
+	cache   map[tlog.Tile][]byte
+	cacheMu sync.Mutex
+}
+
+func (r *tileReader) Height() int {
+	return *height
+}
+
+func (r *tileReader) Reject(tile tlog.Tile) {
+	log.Printf("tile rejected: %v", tile.Path())
+}
+
+// TODO(rsc): Move some variant of this to package tlog
+// once we are more sure of the API.
+
+func (r *tileReader) ReadTiles(tiles []tlog.Tile) ([][]byte, error) {
+	var wg sync.WaitGroup
+	out := make([][]byte, len(tiles))
+	errs := make([]error, len(tiles))
+	r.cacheMu.Lock()
+	if r.cache == nil {
+		r.cache = make(map[tlog.Tile][]byte)
+	}
+	for i, tile := range tiles {
+		if data := r.cache[tile]; data != nil {
+			out[i] = data
+			continue
+		}
+		wg.Add(1)
+		go func(i int, tile tlog.Tile) {
+			defer wg.Done()
+			data, err := httpGet(r.url + tile.Path())
+			if err != nil && tile.W != 1<<uint(tile.H) {
+				fullTile := tile
+				fullTile.W = 1 << uint(tile.H)
+				if fullData, err1 := httpGet(r.url + fullTile.Path()); err1 == nil {
+					data = fullData[:tile.W*tlog.HashSize]
+					err = nil
+				}
+			}
+			if err != nil {
+				errs[i] = err
+				return
+			}
+			r.cacheMu.Lock()
+			r.cache[tile] = data
+			r.cacheMu.Unlock()
+			out[i] = data
+		}(i, tile)
+	}
+	r.cacheMu.Unlock()
+	wg.Wait()
+
+	for _, err := range errs {
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return out, nil
+}
diff --git a/notary/internal/notecheck/test.bash b/notary/internal/notecheck/test.bash
new file mode 100755
index 0000000..d1944a7
--- /dev/null
+++ b/notary/internal/notecheck/test.bash
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+set -e
+go build -o notecheck.exe
+./notecheck.exe "$@" -v rsc-goog.appspot.com+eecb1dec+AbTy1QXWdqYd1TTpuaUqsk6u7p+n4AqLiLB8SBwoB831 test.sum
+rm -f ./notecheck.exe
diff --git a/notary/internal/notecheck/test.sum b/notary/internal/notecheck/test.sum
new file mode 100644
index 0000000..4a8bcd7
--- /dev/null
+++ b/notary/internal/notecheck/test.sum
@@ -0,0 +1,6 @@
+golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c h1:qgOY6WgZOaTkIIMiVjBQcw93ERBE4m30iBm00nkL0i8=
+golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+rsc.io/quote v1.5.2 h1:w5fcysjrx7yqtD/aO+QwRjYZOKnaM9Uh2b40tElTs3Y=
+rsc.io/quote v1.5.2/go.mod h1:LzX7hefJvL54yjefDEDHNONDjII0t9xZLPXsUe+TKr0=
+rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4=
+rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=