notary/internal/tlog: add record formatting and parsing

Move this out of noteweb and into tlog to be near the
tree formatting and parsing, and to allow its use from
other clients/servers.

Change-Id: I7da0f345f81e1204cb343d951e9ee57b72da6fea
Reviewed-on: https://go-review.googlesource.com/c/exp/+/172957
Reviewed-by: Filippo Valsorda <filippo@golang.org>
diff --git a/notary/internal/tlog/note.go b/notary/internal/tlog/note.go
index 2212016..65c7164 100644
--- a/notary/internal/tlog/note.go
+++ b/notary/internal/tlog/note.go
@@ -11,6 +11,7 @@
 	"fmt"
 	"strconv"
 	"strings"
+	"unicode/utf8"
 )
 
 // A Tree is a tree description, to be signed by a go.sum database server.
@@ -68,3 +69,67 @@
 	copy(hash[:], h)
 	return Tree{n, hash}, nil
 }
+
+var errMalformedRecord = errors.New("malformed record data")
+
+// FormatRecord formats a record for serving to a client
+// in a lookup response or data tile.
+//
+// The encoded form is the record ID as a single number,
+// then the text of the record, and then a terminating blank line.
+// Record text must be valid UTF-8 and must not contain any ASCII control
+// characters (those below U+0020) other than newline (U+000A).
+// It must end in a terminating newline and not contain any blank lines.
+func FormatRecord(id int64, text []byte) (msg []byte, err error) {
+	if !isValidRecordText(text) {
+		return nil, errMalformedRecord
+	}
+	msg = []byte(fmt.Sprintf("%d\n", id))
+	msg = append(msg, text...)
+	msg = append(msg, '\n')
+	return msg, nil
+}
+
+// isValidRecordText reports whether text is syntactically valid record text.
+func isValidRecordText(text []byte) bool {
+	var last rune
+	for i := 0; i < len(text); {
+		r, size := utf8.DecodeRune(text[i:])
+		if r < 0x20 && r != '\n' || r == utf8.RuneError && size == 1 || last == '\n' && r == '\n' {
+			return false
+		}
+		i += size
+		last = r
+	}
+	if last != '\n' {
+		return false
+	}
+	return true
+}
+
+// ParseRecord parses a record description at the start of text,
+// stopping immediately after the terminating blank line.
+// It returns the record id, the record text, and the remainder of text.
+func ParseRecord(msg []byte) (id int64, text, rest []byte, err error) {
+	// Leading record id.
+	i := bytes.IndexByte(msg, '\n')
+	if i < 0 {
+		return 0, nil, nil, errMalformedRecord
+	}
+	id, err = strconv.ParseInt(string(msg[:i]), 10, 64)
+	if err != nil {
+		return 0, nil, nil, errMalformedRecord
+	}
+	msg = msg[i+1:]
+
+	// Record text.
+	i = bytes.Index(msg, []byte("\n\n"))
+	if i < 0 {
+		return 0, nil, nil, errMalformedRecord
+	}
+	text, rest = msg[:i+1], msg[i+2:]
+	if !isValidRecordText(text) {
+		return 0, nil, nil, errMalformedRecord
+	}
+	return id, text, rest, nil
+}
diff --git a/notary/internal/tlog/note_test.go b/notary/internal/tlog/note_test.go
index 78061be..a32d6d2 100644
--- a/notary/internal/tlog/note_test.go
+++ b/notary/internal/tlog/note_test.go
@@ -54,3 +54,64 @@
 		}
 	}
 }
+
+func TestFormatRecord(t *testing.T) {
+	id := int64(123456789012)
+	text := "hello, world\n"
+	golden := "123456789012\nhello, world\n\n"
+	msg, err := FormatRecord(id, []byte(text))
+	if err != nil {
+		t.Fatalf("FormatRecord: %v", err)
+	}
+	if string(msg) != golden {
+		t.Fatalf("FormatRecord(...) = %q, want %q", msg, golden)
+	}
+
+	var badTexts = []string{
+		"",
+		"hello\nworld",
+		"hello\n\nworld\n",
+		"hello\x01world\n",
+	}
+	for _, bad := range badTexts {
+		msg, err := FormatRecord(id, []byte(bad))
+		if err == nil {
+			t.Errorf("FormatRecord(id, %q) = %q, want error", bad, msg)
+		}
+	}
+}
+
+func TestParseRecord(t *testing.T) {
+	in := "123456789012\nhello, world\n\njunk on end\x01\xff"
+	goldID := int64(123456789012)
+	goldText := "hello, world\n"
+	goldRest := "junk on end\x01\xff"
+	id, text, rest, err := ParseRecord([]byte(in))
+	if id != goldID || string(text) != goldText || string(rest) != goldRest || err != nil {
+		t.Fatalf("ParseRecord(%q) = %d, %q, %q, %v, want %d, %q, %q, nil", in, id, text, rest, err, goldID, goldText, goldRest)
+	}
+
+	in = "123456789012\nhello, world\n\n"
+	id, text, rest, err = ParseRecord([]byte(in))
+	if id != goldID || string(text) != goldText || len(rest) != 0 || err != nil {
+		t.Fatalf("ParseRecord(%q) = %d, %q, %q, %v, want %d, %q, %q, nil", in, id, text, rest, err, goldID, goldText, "")
+	}
+	if rest == nil {
+		t.Fatalf("ParseRecord(%q): rest = []byte(nil), want []byte{}", in)
+	}
+
+	// Check invalid records.
+	var badRecords = []string{
+		"not-" + in,
+		"123\nhello\x01world\n\n",
+		"123\nhello\xffworld\n\n",
+		"123\nhello world\n",
+		"0x123\nhello world\n\n",
+	}
+	for _, bad := range badRecords {
+		id, text, rest, err := ParseRecord([]byte(bad))
+		if err == nil {
+			t.Fatalf("ParseRecord(%q) = %d, %q, %q, nil, want error", in, id, text, rest)
+		}
+	}
+}