mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-22 10:32:27 +00:00
fix(routes): pin observation evidence to its path representation
This commit is contained in:
1 parent
6ad59d3129
commit
4fda8ed993
9 files changed
+259
-28
No files matched your search
+37
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user