Format 3, step 2: the codec of BODY, security and the head
- Format3 and the control of schema version 3, with the keys of
version 2 (spec 31). The PRELUDE still rejects VERSION 3 until the
reader opens format 3, in step 3, with the test data that expect it.
- The frame of BODY (spec 29.2): AREA_LEN, SECURITY_LEN and HEAD_LEN,
their limits against L and the zeros of the area, all ERR_INTEGRITY.
- security (spec 29.3, 29.7): the outer map, with the signature and
the seal as separately encoded byte strings, and its verdicts X, F0,
F1, S0, S1 and S2, which never fail. The first row that holds
decides, so a seal that breaks its schema is S2 before its type is
read. Writers of this version write it empty, 22 bytes.
- The head (spec 29.4): layer 2 with its type tag and version 1;
layer 3 with the CDDL, R1 and R8; layer 4 in key order, the comment
and the declared author, each file with R2 to R6c, R10 and its
layout, R7 and R9 over the tree, all ERR_HEAD_INVALID, and then the
critical extensions of the new extension.Head object.
- ERR_HEAD_INVALID is declared; All lists it once SpecVersion moves to
0.10 with the test data, in step 6.
- The control tests take format 4 as the caller error that format 3
was, as section 76 of the spec anticipated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
|
|
|
|
package capsule_test
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
|
|
datekeys "g.activething.com/go/DateKeys"
|
|
|
|
|
|
"g.activething.com/go/DateKeys/capsule"
|
|
|
|
|
|
"g.activething.com/go/DateKeys/extension"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Spec §29.2: the frame of BODY and its limits. Every violation is
|
|
|
|
|
|
// ERR_INTEGRITY.
|
|
|
|
|
|
func TestBodyFrame(t *testing.T) {
|
|
|
|
|
|
frame := func(area, sec, head uint32) []byte {
|
|
|
|
|
|
b := capsule.BodyFrame{AreaLen: area, SecurityLen: sec, HeadLen: head}.Bytes()
|
|
|
|
|
|
return b[:]
|
|
|
|
|
|
}
|
|
|
|
|
|
// Valid, at the limits.
|
|
|
|
|
|
for _, c := range []struct {
|
|
|
|
|
|
area, sec, head uint32
|
|
|
|
|
|
l uint64
|
|
|
|
|
|
}{
|
|
|
|
|
|
{512, 22, 53, 577},
|
|
|
|
|
|
{512, 512, 1, 525},
|
|
|
|
|
|
{65536, 1, 1 << 24, 12 + 65536 + 1<<24},
|
|
|
|
|
|
{1024, 1024, 100, 1 << 40},
|
|
|
|
|
|
} {
|
|
|
|
|
|
f, err := capsule.ParseBodyFrame(frame(c.area, c.sec, c.head), c.l)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Errorf("%+v: %v", c, err)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if want := c.l - 12 - uint64(c.area) - uint64(c.head); f.ContentLength(c.l) != want {
|
|
|
|
|
|
t.Errorf("%+v: C = %d, want %d", c, f.ContentLength(c.l), want)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for name, c := range map[string]struct {
|
|
|
|
|
|
area, sec, head uint32
|
|
|
|
|
|
l uint64
|
|
|
|
|
|
}{
|
|
|
|
|
|
"L shorter than the frame": {512, 22, 53, 11},
|
|
|
|
|
|
"AREA_LEN 0": {0, 22, 53, 1000},
|
|
|
|
|
|
"AREA_LEN 511": {511, 22, 53, 1000},
|
|
|
|
|
|
"AREA_LEN 513": {513, 22, 53, 1000},
|
|
|
|
|
|
"AREA_LEN 66048": {66048, 22, 53, 100000},
|
|
|
|
|
|
"SECURITY_LEN 0": {512, 0, 53, 1000},
|
|
|
|
|
|
"SECURITY_LEN above AREA_LEN": {512, 513, 53, 1000},
|
|
|
|
|
|
"HEAD_LEN 0": {512, 22, 0, 1000},
|
|
|
|
|
|
"HEAD_LEN 2^24 + 1": {512, 22, 1<<24 + 1, 1 << 30},
|
|
|
|
|
|
"frame, area and head above L": {512, 22, 53, 576},
|
|
|
|
|
|
} {
|
|
|
|
|
|
if _, err := capsule.ParseBodyFrame(frame(c.area, c.sec, c.head), c.l); !errors.Is(err, datekeys.ErrIntegrity) {
|
|
|
|
|
|
t.Errorf("%s: %v", name, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
area := make([]byte, 512)
|
|
|
|
|
|
copy(area, capsule.EncodeSecurity())
|
|
|
|
|
|
if err := capsule.CheckArea(area, 22); err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
area[511] = 1
|
|
|
|
|
|
if err := capsule.CheckArea(area, 22); !errors.Is(err, datekeys.ErrIntegrity) {
|
|
|
|
|
|
t.Fatalf("a byte of the area that is not zero: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Spec §29.3, §29.7: the security area and its verdicts, which never fail.
|
|
|
|
|
|
func TestSecurityVerdicts(t *testing.T) {
|
|
|
|
|
|
empty := capsule.EncodeSecurity()
|
|
|
|
|
|
if len(empty) != 22 {
|
|
|
|
|
|
t.Fatalf("empty security is %d bytes, want 22", len(empty))
|
|
|
|
|
|
}
|
|
|
|
|
|
sig, err := capsule.EncodeAuthorSignature(1, make([]byte, 32), make([]byte, 64))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(sig) != 105 {
|
|
|
|
|
|
t.Fatalf("an Ed25519 author-signature is %d bytes, want 105", len(sig))
|
|
|
|
|
|
}
|
|
|
|
|
|
seal, err := capsule.EncodeSeal(1, []byte{1, 2, 3})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
with := func(sig, seal []byte) []byte {
|
|
|
|
|
|
b, err := capsule.EncodeSecurityWith(sig, seal)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return b
|
|
|
|
|
|
}
|
|
|
|
|
|
if n := len(with(sig, nil)); n != 130 {
|
|
|
|
|
|
t.Fatalf("security with a signature is %d bytes, want 130", n)
|
|
|
|
|
|
}
|
|
|
|
|
|
x := capsule.Verdicts{Signature: capsule.VerdictUnreadable, Seal: capsule.VerdictUnreadable}
|
|
|
|
|
|
for name, c := range map[string]struct {
|
|
|
|
|
|
b []byte
|
|
|
|
|
|
want capsule.Verdicts
|
|
|
|
|
|
}{
|
|
|
|
|
|
"empty": {empty, capsule.Verdicts{Signature: "F0", Seal: "S0"}},
|
|
|
|
|
|
"a signature of alg 1": {with(sig, nil), capsule.Verdicts{Signature: "F1", Seal: "S0"}},
|
|
|
|
|
|
"a seal of seal_type 1": {with(nil, seal), capsule.Verdicts{Signature: "F0", Seal: "S1"}},
|
|
|
|
|
|
"both": {with(sig, seal), capsule.Verdicts{Signature: "F1", Seal: "S1"}},
|
|
|
|
|
|
"alg 0": {with(mustMarshal(t, map[uint64]any{0: uint64(0), 1: []byte{}, 2: []byte{}}), nil), capsule.Verdicts{Signature: "F1", Seal: "S0"}},
|
|
|
|
|
|
"a signature that is no map": {with([]byte{0x01}, nil), capsule.Verdicts{Signature: "F1", Seal: "S0"}},
|
|
|
|
|
|
// The first row that holds decides: a seal with an unknown key and an
|
|
|
|
|
|
// unknown seal_type breaks its schema, S2, before its type is read.
|
|
|
|
|
|
"a seal with an unknown key": {with(nil, []byte{0xa3, 0x00, 0x07, 0x01, 0x41, 0x00, 0x02, 0x00}), capsule.Verdicts{Signature: "F0", Seal: "S2"}},
|
|
|
|
|
|
"seal_type 0": {with(nil, mustMarshal(t, map[uint64]any{0: uint64(0), 1: []byte{1}})), capsule.Verdicts{Signature: "F0", Seal: "S2"}},
|
|
|
|
|
|
"a seal that is not CBOR": {with(sig, []byte{0xff}), capsule.Verdicts{Signature: "F1", Seal: "S2"}},
|
|
|
|
|
|
"version 2": {mustMarshal(t, map[uint64]any{0: "datekeys-security", 1: uint64(2)}), x},
|
|
|
|
|
|
"another type tag": {mustMarshal(t, map[uint64]any{0: "datekeys-head", 1: uint64(1)}), x},
|
|
|
|
|
|
"an unknown key 4": {mustMarshal(t, map[uint64]any{0: "datekeys-security", 1: uint64(1), 4: []byte{1}}), x},
|
|
|
|
|
|
"key 2 not a byte string": {mustMarshal(t, map[uint64]any{0: "datekeys-security", 1: uint64(1), 2: uint64(1)}), x},
|
|
|
|
|
|
"an empty key 2": {mustMarshal(t, map[uint64]any{0: "datekeys-security", 1: uint64(1), 2: []byte{}}), x},
|
|
|
|
|
|
"a byte more": {append(bytes.Clone(empty), 0), x},
|
|
|
|
|
|
"not CBOR": {[]byte("security"), x},
|
|
|
|
|
|
} {
|
|
|
|
|
|
if got := capsule.EvaluateSecurity(c.b); got != c.want {
|
|
|
|
|
|
t.Errorf("%s: %+v, want %+v", name, got, c.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := x.Lines(); len(got) != 1 || !strings.HasPrefix(got[0], "No se han podido") {
|
|
|
|
|
|
t.Errorf("X shows %q", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := (capsule.Verdicts{Signature: "F0", Seal: "S0"}).Lines(); len(got) != 1 || got[0] != "Sin firma de autor." {
|
|
|
|
|
|
t.Errorf("F0 and S0 show %q", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := (capsule.Verdicts{Signature: "F1", Seal: "S1"}).Lines(); len(got) != 2 {
|
|
|
|
|
|
t.Errorf("F1 and S1 show %q", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func sampleHead() *capsule.Head {
|
|
|
|
|
|
h := &capsule.Head{
|
|
|
|
|
|
Comment: "Para ti ❤️",
|
|
|
|
|
|
Author: "Ana López",
|
|
|
|
|
|
Files: []capsule.File{
|
|
|
|
|
|
{Path: "fotos/playa.jpg", Size: 10, Start: 0, End: 10, MTime: 1759190400, HasMTime: true},
|
|
|
|
|
|
{Path: "nota.txt", Size: 5, Start: 10, End: 15},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
h.Salt[0] = 1
|
|
|
|
|
|
h.Files[0].SHA256[0] = 2
|
|
|
|
|
|
return h
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Spec §29.4: a head round-trips, and its sizes are those of §29.2.
|
|
|
|
|
|
func TestHeadRoundTrip(t *testing.T) {
|
|
|
|
|
|
h := sampleHead()
|
|
|
|
|
|
b, err := capsule.EncodeHead(h)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
got, err := capsule.DecodeHead(b, nil)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got.Comment != h.Comment || got.Author != h.Author || len(got.Files) != 2 || got.Files[0] != h.Files[0] || got.Files[1] != h.Files[1] || got.Salt != h.Salt {
|
|
|
|
|
|
t.Fatalf("round trip: %+v", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := capsule.CheckHeadEnd(got, 15); err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := capsule.CheckHeadEnd(got, 16); !errors.Is(err, datekeys.ErrIntegrity) {
|
|
|
|
|
|
t.Fatalf("files that do not fill the content: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := capsule.CheckHeadEnd(&capsule.Head{}, 0); err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, c := range []struct {
|
|
|
|
|
|
h capsule.Head
|
|
|
|
|
|
size int
|
|
|
|
|
|
}{
|
|
|
|
|
|
{capsule.Head{}, 53},
|
|
|
|
|
|
{capsule.Head{Comment: "x"}, 56},
|
|
|
|
|
|
{capsule.Head{Files: []capsule.File{{Path: "nota.txt", Size: 1000, End: 1000, MTime: 1759190400, HasMTime: true}}}, 117},
|
|
|
|
|
|
{capsule.Head{Files: []capsule.File{{Path: "a"}}}, 100},
|
|
|
|
|
|
} {
|
|
|
|
|
|
b, err := capsule.EncodeHead(&c.h)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(b) != c.size {
|
|
|
|
|
|
t.Errorf("%+v: %d bytes, want %d", c.h, len(b), c.size)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// head builds HEAD_CBOR with the test encoder, for heads that the capsule
|
|
|
|
|
|
// encoder refuses to write.
|
|
|
|
|
|
func head(t *testing.T, fields map[uint64]any) []byte {
|
|
|
|
|
|
m := map[uint64]any{0: "datekeys-head", 1: uint64(1), 2: make([]byte, 32)}
|
|
|
|
|
|
for k, v := range fields {
|
|
|
|
|
|
if v == nil {
|
|
|
|
|
|
delete(m, k)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
m[k] = v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return mustMarshal(t, m)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func file(path string, size, start, end uint64) map[uint64]any {
|
|
|
|
|
|
return map[uint64]any{0: path, 1: size, 2: start, 3: end, 4: make([]byte, 32)}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Spec §29.4, §69.1: the layers of the head and their codes, and the first
|
|
|
|
|
|
// failing layer decides.
|
|
|
|
|
|
func TestDecodeHeadLayers(t *testing.T) {
|
|
|
|
|
|
many := make([]any, 65536)
|
|
|
|
|
|
for i := range many {
|
|
|
|
|
|
many[i] = file(strings.Repeat("a", 1)+string(rune('a'+i%26))+strings.Repeat("x", i/26%3), 0, 0, 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
for name, c := range map[string]struct {
|
|
|
|
|
|
b []byte
|
|
|
|
|
|
want error
|
|
|
|
|
|
}{
|
|
|
|
|
|
// Layer 2.
|
|
|
|
|
|
"another type tag": {mustMarshal(t, map[uint64]any{0: "datekeys-control", 1: uint64(1), 2: make([]byte, 32)}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"version 2": {mustMarshal(t, map[uint64]any{0: "datekeys-head", 1: uint64(2), 2: make([]byte, 32)}), datekeys.ErrUnsupportedVersion},
|
|
|
|
|
|
"version 2 and ..": {mustMarshal(t, map[uint64]any{0: "datekeys-head", 1: uint64(2), 2: make([]byte, 32), 5: []any{file("..", 0, 0, 0)}}), datekeys.ErrUnsupportedVersion},
|
|
|
|
|
|
// Layer 3.
|
|
|
|
|
|
"no salt": {head(t, map[uint64]any{2: nil}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"a salt of 31 bytes": {head(t, map[uint64]any{2: make([]byte, 31)}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"an empty comment": {head(t, map[uint64]any{3: ""}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"a comment of 16385 bytes": {head(t, map[uint64]any{3: strings.Repeat("a", 16385)}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"an author of 257 bytes": {head(t, map[uint64]any{4: strings.Repeat("a", 257)}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"an empty array of files": {head(t, map[uint64]any{5: []any{}}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"65536 files": {head(t, map[uint64]any{5: many}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"R1: an empty path": {head(t, map[uint64]any{5: []any{file("", 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"R1: a path of 1025 bytes": {head(t, map[uint64]any{5: []any{file(strings.Repeat("a", 1025), 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"R8: b before a": {head(t, map[uint64]any{5: []any{file("b", 0, 0, 0), file("a", 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"R8: a repeated path": {head(t, map[uint64]any{5: []any{file("a", 0, 0, 0), file("a", 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"R8 before R3: b/.. and a": {head(t, map[uint64]any{5: []any{file("b/..", 0, 0, 0), file("a", 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"a size above L_MAX": {head(t, map[uint64]any{5: []any{file("a", capsule.MaxPayloadLength+1, 0, 0)}}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"an mtime after 9999": {head(t, map[uint64]any{5: []any{map[uint64]any{0: "a", 1: uint64(0), 2: uint64(0), 3: uint64(0), 4: make([]byte, 32), 5: uint64(253402300800)}}}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"an unknown key 8": {head(t, map[uint64]any{8: uint64(1)}), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
"a byte more": {append(head(t, nil), 0), datekeys.ErrNonCanonicalCBOR},
|
|
|
|
|
|
// Layer 4, in key order.
|
|
|
|
|
|
"a comment with U+202E": {head(t, map[uint64]any{3: "ab"}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"a comment with the tag U+E0041": {head(t, map[uint64]any{3: "a\U000E0041"}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"an author with LF": {head(t, map[uint64]any{4: "a\nb"}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"R3: ..": {head(t, map[uint64]any{5: []any{file("..", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"R2: /a": {head(t, map[uint64]any{5: []any{file("/a", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"R4b: a and VS16": {head(t, map[uint64]any{5: []any{file("a️", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"R6: CON.txt": {head(t, map[uint64]any{5: []any{file("CON.txt", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"R10: .datekeys-x": {head(t, map[uint64]any{5: []any{file(".datekeys-x", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"layout: a first start that is not 0": {head(t, map[uint64]any{5: []any{file("a", 1, 1, 2)}}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"layout: end minus start is not size": {head(t, map[uint64]any{5: []any{file("a", 2, 0, 1)}}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"layout: a gap": {head(t, map[uint64]any{5: []any{file("a", 1, 0, 1), file("b", 1, 2, 3)}}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"R7: A.txt and a.txt": {head(t, map[uint64]any{5: []any{file("A.txt", 0, 0, 0), file("a.txt", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"a comment and a path that break": {head(t, map[uint64]any{3: "a", 5: []any{file("..", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"a path that breaks, then an unknown critical extension": {head(t, map[uint64]any{5: []any{file("..", 0, 0, 0)}, 6: []any{map[uint64]any{0: "x.example", 1: uint64(1)}}}), datekeys.ErrHeadInvalid},
|
|
|
|
|
|
"an unknown critical extension": {head(t, map[uint64]any{6: []any{map[uint64]any{0: "x.example", 1: uint64(1)}}}), datekeys.ErrExtensionCriticalUnknown},
|
|
|
|
|
|
} {
|
|
|
|
|
|
if _, err := capsule.DecodeHead(c.b, nil); !errors.Is(err, c.want) {
|
|
|
|
|
|
t.Errorf("%s: %v, want %v", name, err, c.want)
|
|
|
|
|
|
} else if n := codes(err); n != 1 {
|
|
|
|
|
|
t.Errorf("%s: %d normative codes in %v", name, n, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// A known critical extension of the head passes.
|
|
|
|
|
|
b := head(t, map[uint64]any{6: []any{map[uint64]any{0: "x.example", 1: uint64(1)}}})
|
|
|
|
|
|
if _, err := capsule.DecodeHead(b, extension.Set{"x.example": {1}}); err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// codes counts the normative errors err wraps, ERR_HEAD_INVALID included.
|
|
|
|
|
|
func codes(err error) int {
|
|
|
|
|
|
n := 0
|
|
|
|
|
|
for _, e := range append(datekeys.All(), datekeys.ErrHeadInvalid) {
|
|
|
|
|
|
if errors.Is(err, e) {
|
|
|
|
|
|
n++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return n
|
|
|
|
|
|
}
|