fix(routes): pin observation evidence to its path representation

This commit is contained in:
n30nex committed 2026-10-02 08:56:40 -04:00
1 parent 6ad59d3129
commit 4fda8ed993
9 files changed
+259 -28

No files matched your search

+37 -2
View File
@@ -39,6 +39,16 @@ func (s *Store) GetRouteEvidence(ctx context.Context, iata, key string, query ap
if c.IATA != iata || c.PathKey != key || !c.Since.Equal(query.Since) || !c.Until.Equal(query.Until) || c.ID <= 0 || c.HeardAt.Before(query.Since) || !c.HeardAt.Before(query.Until) {
return nil, api.ErrRouteEvidenceInput
}
if c.HashSize != 0 || c.PathBytes != "" {
if (query.HashSize != 0 || query.PathBytes != "") && (query.HashSize != c.HashSize || query.PathBytes != c.PathBytes) {
return nil, api.ErrRouteEvidenceInput
}
query.HashSize, query.PathBytes = c.HashSize, c.PathBytes
}
}
pinned := query.HashSize != 0 || query.PathBytes != ""
if pinned && !api.ValidRouteEvidencePath(query.HashSize, query.PathBytes) {
return nil, api.ErrRouteEvidenceInput
}
keyBytes, _ := hex.DecodeString(key)
row, err := s.q.GetRouteEvidenceRoute(ctx, sqlc.GetRouteEvidenceRouteParams{Iata: iata, PathKey: keyBytes})
@@ -49,10 +59,35 @@ func (s *Store) GetRouteEvidence(ctx context.Context, iata, key string, query ap
if err != nil {
return nil, err
}
width, path, valid := savedRoutePath(row.HashPrefix, row.HopCount)
if pinned {
selected, _ := hex.DecodeString(query.PathBytes)
hops := len(selected) / int(query.HashSize)
if hops != len(row.NodeIds) || hops != int(row.HopCount) {
return nil, api.ErrRouteEvidenceInput
}
prefixes := make([][]byte, hops)
for i, id := range row.NodeIds {
prefixes[i] = selected[i*int(query.HashSize) : (i+1)*int(query.HashSize)]
// A changed representation must still belong to this node chain.
// Reuse the already-loaded keys; never widen the evidence query.
if !valid || width != query.HashSize || !bytes.Equal(path, selected) {
node := nodes[id]
if node == nil {
return nil, api.ErrRouteEvidenceInput
}
pubkey, err := hex.DecodeString(node.PublicKey)
if err != nil || !bytes.HasPrefix(pubkey, prefixes[i]) {
return nil, api.ErrRouteEvidenceInput
}
}
}
row.HashPrefix = prefixes
width, path, valid = query.HashSize, selected, true
}
route := toKnownRoutes([]knownRouteRow{{ID: row.ID, NodeIds: row.NodeIds, HashPrefix: row.HashPrefix, Iata: row.Iata, HopCount: row.HopCount, FirstSeen: row.FirstSeen, LastSeen: row.LastSeen, ObservationCount: row.ObservationCount}}, nodes)[0]
route.PathKey = hex.EncodeToString(row.PathKey)
out := &api.RouteEvidence{Items: []api.RouteObservation{}, Route: route, WindowStart: query.Since.UnixMilli(), WindowEnd: query.Until.UnixMilli(), GeneratedAt: time.Now().UnixMilli(), MatchType: "saved_path_prefixes"}
width, path, valid := savedRoutePath(row.HashPrefix, row.HopCount)
if !valid {
return out, nil
}
@@ -86,7 +121,7 @@ func (s *Store) GetRouteEvidence(ctx context.Context, iata, key string, query ap
}
if out.HasMore {
last := rows[len(rows)-1]
cursor := (api.RouteEvidenceCursor{IATA: iata, PathKey: key, Since: query.Since, Until: query.Until, HeardAt: last.HeardAt.Time, ID: last.ID}).String()
cursor := (api.RouteEvidenceCursor{IATA: iata, PathKey: key, Since: query.Since, Until: query.Until, HeardAt: last.HeardAt.Time, ID: last.ID, HashSize: width, PathBytes: out.PathBytes}).String()
out.NextPageCursor = &cursor
}
return out, nil
+70 -1
View File
@@ -36,12 +36,13 @@ func TestRouteEvidencePostgres(t *testing.T) {
applyBaseline(t, ctx, tx)
_, err := tx.Exec(ctx, `
INSERT INTO observers(id,public_key,display_name) VALUES ('00000000-0000-0000-0000-000000000001','\x01','One'),('00000000-0000-0000-0000-000000000002','\x02','Two');
INSERT INTO nodes(id,public_key,node_type,name) VALUES ('00000000-0000-0000-0000-000000000001','\xaa',2,'A'),('00000000-0000-0000-0000-000000000002','\xbb',2,'B');
INSERT INTO nodes(id,public_key,node_type,name) VALUES ('00000000-0000-0000-0000-000000000001','\xaa0102',2,'A'),('00000000-0000-0000-0000-000000000002','\xbb0304',2,'B');
INSERT INTO packets(packet_hash,payload_type,payload_version,route_type,raw_payload,raw_header,first_heard_at,last_heard_at)
SELECT int4send(i),4,0,1,'\x00','\x00',NOW(),NOW() FROM generate_series(1,10) i;`)
if err != nil {
t.Fatal(err)
}
capture := &evidenceCapture{Tx: tx}
store := &Store{q: sqlc.New(capture)}
ids := []uuid.UUID{uuid.MustParse("00000000-0000-0000-0000-000000000001"), uuid.MustParse("00000000-0000-0000-0000-000000000002")}
@@ -80,6 +81,36 @@ func TestRouteEvidencePostgres(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if query.Cursor.HashSize != 1 || query.Cursor.PathBytes != "aabb" {
t.Fatalf("cursor did not pin the original bytes: %+v", query.Cursor)
}
// A new representation arrives while page one is open. New reads follow it,
// but pagination and copied links retain the exact original representation.
if _, err = tx.Exec(ctx, `INSERT INTO packets(packet_hash,payload_type,payload_version,route_type,raw_payload,raw_header,first_heard_at,last_heard_at) SELECT int4send(i),4,0,1,'\x00','\x00',NOW(),NOW() FROM generate_series(12,14) i`); err != nil {
t.Fatal(err)
}
_, err = tx.Exec(ctx, `INSERT INTO packet_observations(id,packet_hash,observer_id,iata,heard_at,hash_size,hop_count,path_bytes,payload_type,path_length_byte)
SELECT i,int4send(i),'00000000-0000-0000-0000-000000000001','YOW',$1,2,2,'\xaa01bb03',4,66 FROM generate_series(12,14) i`, at)
if err != nil {
t.Fatal(err)
}
if err := store.UpsertKnownRoute(ctx, ids, [][]byte{{0xaa, 1}, {0xbb, 3}}, "YOW", 2); err != nil {
t.Fatal(err)
}
freshQuery := api.RouteEvidenceQuery{Since: since, Until: until, Limit: 2}
fresh, err := store.GetRouteEvidence(ctx, "YOW", key, freshQuery)
if err != nil || fresh.HashSize != 2 || fresh.PathBytes != "aa01bb03" || len(fresh.Items) != 2 || fresh.Items[0].ID != 14 || fresh.Route.Hops[0].HashBytes != "aa01" {
t.Fatalf("new representation lost: %+v %v", fresh, err)
}
if fresh.Route.ID != first.Route.ID || fresh.Route.PathKey != first.Route.PathKey || fresh.Route.FirstSeen != first.Route.FirstSeen || fresh.Route.ObservationCount != first.Route.ObservationCount+1 {
t.Fatalf("route identity/counters changed: %+v", fresh.Route)
}
shared := freshQuery
shared.HashSize, shared.PathBytes = first.HashSize, first.PathBytes
copyPage, err := store.GetRouteEvidence(ctx, "YOW", key, shared)
if err != nil || len(copyPage.Items) != 2 || copyPage.Items[0].ID != 3 || copyPage.Route.Hops[0].HashBytes != "aa" {
t.Fatalf("shared representation changed: %+v %v", copyPage, err)
}
second, err := store.GetRouteEvidence(ctx, "YOW", key, query)
if err != nil {
t.Fatal(err)
@@ -90,6 +121,44 @@ func TestRouteEvidencePostgres(t *testing.T) {
if _, err = json.Marshal(second); err != nil {
t.Fatalf("non-finite JSON: %v", err)
}
for _, prefixes := range [][][]byte{{{0xaa, 1, 2}, {0xbb, 3, 4}}, {{0xaa, 1, 2}, {0xbb, 3, 4}}} {
if err := store.UpsertKnownRoute(ctx, ids, prefixes, "YOW", 2); err != nil {
t.Fatal(err)
}
}
thirdWidth, err := store.GetRouteEvidence(ctx, "YOW", key, freshQuery)
if err != nil || thirdWidth.HashSize != 3 || thirdWidth.PathBytes != "aa0102bb0304" || len(thirdWidth.Items) != 0 || thirdWidth.Route.ID != first.Route.ID || thirdWidth.Route.ObservationCount != first.Route.ObservationCount+3 {
t.Fatalf("third width or repeated upsert: %+v %v", thirdWidth, err)
}
// Missing identities or mismatched bytes cannot make a pinned query search
// a different chain. Prefix collisions likewise never widen the SQL match.
wrong := shared
wrong.PathBytes = "aacc"
if _, err := store.GetRouteEvidence(ctx, "YOW", key, wrong); !errors.Is(err, api.ErrRouteEvidenceInput) {
t.Fatalf("unrelated path accepted: %v", err)
}
wrong.PathBytes = "aabbcc"
if _, err := store.GetRouteEvidence(ctx, "YOW", key, wrong); !errors.Is(err, api.ErrRouteEvidenceInput) {
t.Fatalf("wrong hop count accepted: %v", err)
}
_, err = tx.Exec(ctx, `INSERT INTO nodes(id,public_key,node_type,name) VALUES ('00000000-0000-0000-0000-000000000003','\xaa9988',2,'Collision');
DELETE FROM nodes WHERE id='00000000-0000-0000-0000-000000000001'`)
if err != nil {
t.Fatal(err)
}
if _, err := store.GetRouteEvidence(ctx, "YOW", key, shared); !errors.Is(err, api.ErrRouteEvidenceInput) {
t.Fatalf("missing node substituted by colliding node: %v", err)
}
if _, err = tx.Exec(ctx, `INSERT INTO nodes(id,public_key,node_type,name) VALUES ('00000000-0000-0000-0000-000000000001','\xaa0102',2,'A')`); err != nil {
t.Fatal(err)
}
copyPage, err = store.GetRouteEvidence(ctx, "YOW", key, shared)
if err != nil || len(copyPage.Items) != 2 || copyPage.Items[0].ID != 3 {
t.Fatalf("collision broadened evidence: %+v %v", copyPage, err)
}
if err := store.UpsertKnownRoute(ctx, ids, [][]byte{{0xaa}, {0xbb}}, "YOW", 2); err != nil {
t.Fatal(err)
}
query.Cursor.IATA = "YVR"
if _, err = store.GetRouteEvidence(ctx, "YOW", key, query); !errors.Is(err, api.ErrRouteEvidenceInput) {
t.Fatal("cross-route cursor accepted")
+16 -2
View File
@@ -2139,7 +2139,7 @@ const docTemplate = `{
},
"/routes/{iata}/{pathKey}/observations": {
"get": {
"description": "Matches the saved IATA, complete path bytes and hash width. Excludes TRACE and unclassified reports; other widths and search-result subsegments are not included. Byte matches do not prove hop identities or delivery. The route and its historical counter can outlive raw evidence. Cursors retain full timestamp precision and pin route/window scope.",
"description": "Matches one complete path representation in the saved IATA. Defaults to the latest processed prefixes; hashSize/pathBytes pin a prior response. Excludes TRACE and unclassified reports; widths are never combined. Pinned prefixes must match the saved node chain or its current metadata. Byte matches do not prove hop identities or delivery. The route counter can outlive raw evidence. New cursors pin path, route, window and full timestamp precision; legacy unpinned cursors remain supported.",
"produces": [
"application/json"
],
@@ -2180,9 +2180,23 @@ const docTemplate = `{
"name": "until",
"in": "query"
},
{
"maximum": 3,
"minimum": 1,
"type": "integer",
"description": "Exact hash width (1–3); provide with pathBytes to pin a response's representation",
"name": "hashSize",
"in": "query"
},
{
"type": "string",
"description": "Opaque precise cursor from nextPageCursor; window is pinned",
"description": "Complete lower-case hex path from the response; provide with hashSize",
"name": "pathBytes",
"in": "query"
},
{
"type": "string",
"description": "Opaque precise cursor from nextPageCursor; route, window and path are pinned",
"name": "pageCursor",
"in": "query"
},
+16 -2
View File
@@ -2137,7 +2137,7 @@
},
"/routes/{iata}/{pathKey}/observations": {
"get": {
"description": "Matches the saved IATA, complete path bytes and hash width. Excludes TRACE and unclassified reports; other widths and search-result subsegments are not included. Byte matches do not prove hop identities or delivery. The route and its historical counter can outlive raw evidence. Cursors retain full timestamp precision and pin route/window scope.",
"description": "Matches one complete path representation in the saved IATA. Defaults to the latest processed prefixes; hashSize/pathBytes pin a prior response. Excludes TRACE and unclassified reports; widths are never combined. Pinned prefixes must match the saved node chain or its current metadata. Byte matches do not prove hop identities or delivery. The route counter can outlive raw evidence. New cursors pin path, route, window and full timestamp precision; legacy unpinned cursors remain supported.",
"produces": [
"application/json"
],
@@ -2178,9 +2178,23 @@
"name": "until",
"in": "query"
},
{
"maximum": 3,
"minimum": 1,
"type": "integer",
"description": "Exact hash width (1–3); provide with pathBytes to pin a response's representation",
"name": "hashSize",
"in": "query"
},
{
"type": "string",
"description": "Opaque precise cursor from nextPageCursor; window is pinned",
"description": "Complete lower-case hex path from the response; provide with hashSize",
"name": "pathBytes",
"in": "query"
},
{
"type": "string",
"description": "Opaque precise cursor from nextPageCursor; route, window and path are pinned",
"name": "pageCursor",
"in": "query"
},
+21 -6
View File
@@ -2994,11 +2994,13 @@ paths:
- Routes
/routes/{iata}/{pathKey}/observations:
get:
description: Matches the saved IATA, complete path bytes and hash width. Excludes
TRACE and unclassified reports; other widths and search-result subsegments
are not included. Byte matches do not prove hop identities or delivery. The
route and its historical counter can outlive raw evidence. Cursors retain
full timestamp precision and pin route/window scope.
description: Matches one complete path representation in the saved IATA. Defaults
to the latest processed prefixes; hashSize/pathBytes pin a prior response.
Excludes TRACE and unclassified reports; widths are never combined. Pinned
prefixes must match the saved node chain or its current metadata. Byte matches
do not prove hop identities or delivery. The route counter can outlive raw
evidence. New cursors pin path, route, window and full timestamp precision;
legacy unpinned cursors remain supported.
parameters:
- description: Three-character IATA code
in: path
@@ -3024,7 +3026,20 @@ paths:
in: query
name: until
type: integer
- description: Opaque precise cursor from nextPageCursor; window is pinned
- description: Exact hash width (1–3); provide with pathBytes to pin a response's
representation
in: query
maximum: 3
minimum: 1
name: hashSize
type: integer
- description: Complete lower-case hex path from the response; provide with
hashSize
in: query
name: pathBytes
type: string
- description: Opaque precise cursor from nextPageCursor; route, window and
path are pinned
in: query
name: pageCursor
type: string
+18 -4
View File
@@ -17,7 +17,7 @@ import (
func routeEvidenceQuery(r *http.Request, iata, key string, now time.Time) (api.RouteEvidenceQuery, error) {
q := api.RouteEvidenceQuery{}
values := r.URL.Query()
for _, name := range []string{"pageCursor", "since", "until", "range", "limit"} {
for _, name := range []string{"pageCursor", "since", "until", "range", "limit", "hashSize", "pathBytes"} {
if len(values[name]) > 1 {
return q, api.ErrRouteEvidenceInput
}
@@ -38,6 +38,7 @@ func routeEvidenceQuery(r *http.Request, iata, key string, now time.Time) (api.R
q.Cursor = c
q.Since = c.Since
q.Until = c.Until
q.HashSize, q.PathBytes = c.HashSize, c.PathBytes
} else {
q.Until = now.UTC().Truncate(time.Millisecond)
q.Since = q.Until.Add(-24 * time.Hour)
@@ -67,6 +68,17 @@ func routeEvidenceQuery(r *http.Request, iata, key string, now time.Time) (api.R
return q, api.ErrRouteEvidenceInput
}
}
if values.Has("hashSize") || values.Has("pathBytes") {
width, err := strconv.ParseInt(values.Get("hashSize"), 10, 16)
path := values.Get("pathBytes")
if err != nil || !api.ValidRouteEvidencePath(int16(width), path) || strconv.FormatInt(width, 10) != values.Get("hashSize") {
return q, api.ErrRouteEvidenceInput
}
if q.Cursor != nil && q.Cursor.HashSize != 0 && (q.Cursor.HashSize != int16(width) || q.Cursor.PathBytes != path) {
return q, api.ErrRouteEvidenceInput
}
q.HashSize, q.PathBytes = int16(width), path
}
if !api.ValidRouteEvidenceWindow(q.Since, q.Until) || q.Until.After(now.Add(clockSkewTolerance)) {
return q, api.ErrRouteEvidenceInput
}
@@ -76,7 +88,7 @@ func routeEvidenceQuery(r *http.Request, iata, key string, now time.Time) (api.R
// getRouteEvidence godoc
//
// @Summary Get retained reports matching a full saved route prefix sequence
// @Description Matches the saved IATA, complete path bytes and hash width. Excludes TRACE and unclassified reports; other widths and search-result subsegments are not included. Byte matches do not prove hop identities or delivery. The route and its historical counter can outlive raw evidence. Cursors retain full timestamp precision and pin route/window scope.
// @Description Matches one complete path representation in the saved IATA. Defaults to the latest processed prefixes; hashSize/pathBytes pin a prior response. Excludes TRACE and unclassified reports; widths are never combined. Pinned prefixes must match the saved node chain or its current metadata. Byte matches do not prove hop identities or delivery. The route counter can outlive raw evidence. New cursors pin path, route, window and full timestamp precision; legacy unpinned cursors remain supported.
// @Tags Routes
// @Produce json
// @Param iata path string true "Three-character IATA code"
@@ -84,7 +96,9 @@ func routeEvidenceQuery(r *http.Request, iata, key string, now time.Time) (api.R
// @Param range query string false "Server-anchored duration (default 24h, max 720h); exclusive with since/until/pageCursor"
// @Param since query int false "Window start epoch ms; provide with until (default last 24h)"
// @Param until query int false "Exclusive window end epoch ms; maximum span 30d, end may be up to 5 minutes ahead of server time (clock skew tolerance)"
// @Param pageCursor query string false "Opaque precise cursor from nextPageCursor; window is pinned"
// @Param hashSize query int false "Exact hash width (1–3); provide with pathBytes to pin a response's representation" minimum(1) maximum(3)
// @Param pathBytes query string false "Complete lower-case hex path from the response; provide with hashSize"
// @Param pageCursor query string false "Opaque precise cursor from nextPageCursor; route, window and path are pinned"
// @Param limit query int false "Default 50; positive, capped at 200" minimum(1) maximum(200)
// @Success 200 {object} api.RouteEvidence
// @Failure 400 {object} handlers.APIError
@@ -100,7 +114,7 @@ func getRouteEvidence(reader api.Reader) http.HandlerFunc {
}
query, err := routeEvidenceQuery(r, iata, key, time.Now())
if err != nil {
respondError(w, 400, "invalid route evidence window, limit or page cursor")
respondError(w, 400, "invalid route evidence window, path, limit or page cursor")
return
}
result, err := reader.GetRouteEvidence(r.Context(), iata, key, query)
@@ -85,3 +85,28 @@ func TestRouteEvidenceHandler(t *testing.T) {
}
}
}
func TestRouteEvidencePinnedQuery(t *testing.T) {
now := time.Now().UTC().Truncate(time.Millisecond)
q, err := routeEvidenceQuery(httptest.NewRequest("GET", "/?hashSize=2&pathBytes=aa01bb02", nil), "YOW", evidenceKey, now)
if err != nil || q.HashSize != 2 || q.PathBytes != "aa01bb02" {
t.Fatalf("selected path: %+v %v", q, err)
}
c := api.RouteEvidenceCursor{IATA: "YOW", PathKey: evidenceKey, Since: q.Since, Until: q.Until, HeardAt: q.Since.Add(time.Hour), ID: 5, HashSize: q.HashSize, PathBytes: q.PathBytes}
encoded := url.QueryEscape(c.String())
next, err := routeEvidenceQuery(httptest.NewRequest("GET", "/?pageCursor="+encoded, nil), "YOW", evidenceKey, now)
if err != nil || next.HashSize != 2 || next.PathBytes != q.PathBytes {
t.Fatalf("cursor path lost: %+v %v", next, err)
}
for _, query := range []string{
"hashSize=1", "pathBytes=aabb", "hashSize=0&pathBytes=aabb", "hashSize=4&pathBytes=aa01bb02",
"hashSize=01&pathBytes=aabb", "hashSize=1&hashSize=2&pathBytes=aabb",
"hashSize=1&pathBytes=aabb&pathBytes=aabb", "hashSize=1&pathBytes=aa", "hashSize=1&pathBytes=AABB",
"pageCursor=" + encoded + "&hashSize=1&pathBytes=aabb",
"pageCursor=" + encoded + "&hashSize=2&pathBytes=aa02bb01",
} {
if _, err := routeEvidenceQuery(httptest.NewRequest("GET", "/?"+query, nil), "YOW", evidenceKey, now); err == nil {
t.Errorf("accepted %s", query)
}
}
}
+36 -11
View File
@@ -50,16 +50,22 @@ type RouteEvidenceQuery struct {
Until time.Time
Cursor *RouteEvidenceCursor
Limit int32
// Optional exact representation copied from a prior response or cursor.
HashSize int16
PathBytes string
}
// RouteEvidenceCursor binds the precise (heard_at DESC,id DESC) boundary to its route/window.
// RouteEvidenceCursor pins the route, window and exact representation as well as
// the precise (heard_at DESC,id DESC) boundary. Legacy v1 cursors have no path pin.
type RouteEvidenceCursor struct {
IATA string
PathKey string
Since time.Time
Until time.Time
HeardAt time.Time
ID int64
IATA string
PathKey string
Since time.Time
Until time.Time
HeardAt time.Time
ID int64
HashSize int16
PathBytes string
}
func ValidRouteEvidenceKey(iata, key string) bool {
@@ -79,20 +85,32 @@ func ValidRouteEvidenceWindow(since, until time.Time) bool {
return !since.Before(time.Unix(0, 0)) && until.After(since) && until.Sub(since) <= MaxRouteEvidenceWindow
}
func ValidRouteEvidencePath(width int16, path string) bool {
if width < 1 || width > 3 || len(path) < 4*int(width) || len(path) > 126*int(width) || len(path)%(2*int(width)) != 0 {
return false
}
_, err := hex.DecodeString(path)
return err == nil && path == strings.ToLower(path)
}
func (c RouteEvidenceCursor) String() string {
return fmt.Sprintf("v1:%s:%s:%d:%d:%d:%d", c.IATA, c.PathKey, c.Since.UnixMilli(), c.Until.UnixMilli(), c.HeardAt.UnixMicro(), c.ID)
value := fmt.Sprintf("%s:%s:%d:%d:%d:%d", c.IATA, c.PathKey, c.Since.UnixMilli(), c.Until.UnixMilli(), c.HeardAt.UnixMicro(), c.ID)
if c.HashSize != 0 || c.PathBytes != "" {
return fmt.Sprintf("v2:%s:%d:%s", value, c.HashSize, c.PathBytes)
}
return "v1:" + value
}
func ParseRouteEvidenceCursor(raw string) (*RouteEvidenceCursor, error) {
if len(raw) > 200 {
if len(raw) > 512 {
return nil, ErrRouteEvidenceInput
}
p := strings.Split(raw, ":")
if len(p) != 7 || p[0] != "v1" || !ValidRouteEvidenceKey(p[1], p[2]) {
if !((len(p) == 7 && p[0] == "v1") || (len(p) == 9 && p[0] == "v2")) || !ValidRouteEvidenceKey(p[1], p[2]) {
return nil, ErrRouteEvidenceInput
}
values := [4]int64{}
for i, s := range p[3:] {
for i, s := range p[3:7] {
n, err := strconv.ParseInt(s, 10, 64)
if err != nil || n < 0 || strconv.FormatInt(n, 10) != s {
return nil, ErrRouteEvidenceInput
@@ -104,6 +122,13 @@ func ParseRouteEvidenceCursor(raw string) (*RouteEvidenceCursor, error) {
return nil, ErrRouteEvidenceInput
}
c := &RouteEvidenceCursor{IATA: p[1], PathKey: p[2], Since: time.UnixMilli(values[0]).UTC(), Until: time.UnixMilli(values[1]).UTC(), HeardAt: time.UnixMicro(values[2]).UTC(), ID: values[3]}
if p[0] == "v2" {
width, err := strconv.ParseInt(p[7], 10, 16)
if err != nil || strconv.FormatInt(width, 10) != p[7] || !ValidRouteEvidencePath(int16(width), p[8]) {
return nil, ErrRouteEvidenceInput
}
c.HashSize, c.PathBytes = int16(width), p[8]
}
if !ValidRouteEvidenceWindow(c.Since, c.Until) || c.HeardAt.Before(c.Since) || !c.HeardAt.Before(c.Until) {
return nil, ErrRouteEvidenceInput
}
+20
View File
@@ -23,3 +23,23 @@ func TestRouteEvidenceCursor(t *testing.T) {
}
}
}
func TestRouteEvidencePinnedCursor(t *testing.T) {
c := RouteEvidenceCursor{IATA: "YOW", PathKey: strings.Repeat("a", 32), Since: time.UnixMilli(1000).UTC(), Until: time.UnixMilli(3000).UTC(), HeardAt: time.UnixMicro(2000123).UTC(), ID: 9, HashSize: 3, PathBytes: strings.Repeat("aabbcc", 63)}
got, err := ParseRouteEvidenceCursor(c.String())
if err != nil || *got != c || !strings.HasPrefix(c.String(), "v2:") {
t.Fatalf("pinned cursor round-trip: %+v %v", got, err)
}
for _, bad := range []string{
strings.Replace(c.String(), ":3:", ":4:", 1),
strings.Replace(c.String(), ":3:", ":03:", 1),
c.String() + "00",
strings.TrimSuffix(c.String(), c.PathBytes) + "aabbcc",
strings.ToUpper(c.String()),
strings.TrimSuffix(c.String(), c.PathBytes) + "zzzzzzzzzzzz",
} {
if _, err := ParseRouteEvidenceCursor(bad); err == nil {
t.Errorf("accepted invalid pinned cursor %q", bad)
}
}
}