fix: normalize configured_scope_at to canonical UTC before last-write-wins

SaarMesh-Bot flagged on #1865 (PR #1867 commit 631686a) that
UpdateNodeConfiguredScope stored the report timestamp raw and compared it
lexicographically. Real firmware emits microsecond+offset timestamps and
different observers may use "Z" or non-UTC offsets, so string comparison
can diverge from chronological order and let a stale report win.

normalizeReportTS parses RFC3339Nano/RFC3339 and stores canonical UTC
RFC3339, used for both storage and the comparison.
This commit is contained in:
dborup
2026-07-26 17:19:28 +02:00
parent a7f4a85009
commit d9c454030c
2 changed files with 74 additions and 1 deletions
+24 -1
View File
@@ -1714,6 +1714,25 @@ func (s *Store) UpdateNodeDefaultScope(pubkey, scope string) error {
return err
}
// normalizeReportTS parses an observer report timestamp and returns it in
// canonical UTC RFC3339 form ("2006-01-02T15:04:05Z"). It accepts both the
// firmware's fractional/offset form (e.g. "2026-07-26T09:43:48.000000+00:00")
// and plain "Z"/offset variants. An empty or unparseable input returns "" so
// the caller writes an empty configured_scope_at and skips the ordering guard;
// this keeps every stored timestamp either canonical or empty, never a mix of
// offset/precision formats that would break lexicographic last-write-wins.
func normalizeReportTS(raw string) string {
if raw == "" {
return ""
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, raw); err == nil {
return t.UTC().Format(time.RFC3339)
}
}
return ""
}
// UpdateNodeConfiguredScope records the region scopes a node has CONFIGURED,
// as concrete evidence from an observer /neighbors report (#1865). Unlike
// UpdateNodeDefaultScope (inferred, overwritten on every observation), this is
@@ -1731,11 +1750,15 @@ func (s *Store) UpdateNodeConfiguredScope(pubkey, scope, reportedAt string) erro
return nil
}
scope = normalizeConfiguredScopeList(scope)
// Normalize to canonical UTC RFC3339 so the last-write-wins comparison is
// chronological, not lexicographic (see normalizeReportTS). Stored values
// are therefore always canonical or empty.
reportedAt = normalizeReportTS(reportedAt)
// Last-write-wins: skip if the stored confirmation is newer-or-equal.
if reportedAt != "" {
var curAt sql.NullString
row := s.db.QueryRow(`SELECT configured_scope_at FROM nodes WHERE public_key = ?`, pubkey)
if row.Scan(&curAt) == nil && curAt.Valid && curAt.String >= reportedAt {
if row.Scan(&curAt) == nil && curAt.Valid && curAt.String != "" && curAt.String >= reportedAt {
return nil
}
}
+50
View File
@@ -158,3 +158,53 @@ func TestUpdateNodeConfiguredScopeLastWriteWins(t *testing.T) {
t.Errorf("inactive_nodes.configured_scope = %q, want '#de'", inactive.String)
}
}
// TestUpdateNodeConfiguredScopeNormalizesAndOrdersByInstant proves the
// last-write-wins guard orders by chronological instant, not by raw string.
// A "+02:00" report that is lexicographically "greater" but chronologically
// EARLIER than the stored UTC value must not win, and stored timestamps are
// canonicalized to UTC "Z" form regardless of the incoming offset/precision.
// Flagged upstream on #1865 (SaarMesh-Bot, PR #1867 commit 631686a).
func TestUpdateNodeConfiguredScopeNormalizesAndOrdersByInstant(t *testing.T) {
store := openNeighborsStore(t)
pk := "ee00000000000000000000000000000000000000000000000000000000000001"
seedNode(t, store, pk)
// Baseline: noon UTC.
if err := store.UpdateNodeConfiguredScope(pk, "eu", "2026-07-25T12:00:00Z"); err != nil {
t.Fatal(err)
}
if _, at := configuredScope(t, store, pk); at.String != "2026-07-25T12:00:00Z" {
t.Fatalf("stored at = %q, want canonical 'Z' form", at.String)
}
// "2026-07-25T13:30:00+02:00" == 11:30Z, chronologically EARLIER than 12:00Z,
// but lexicographically GREATER ("13:30..." > "12:00...Z"). Must be skipped.
if err := store.UpdateNodeConfiguredScope(pk, "stale", "2026-07-25T13:30:00+02:00"); err != nil {
t.Fatal(err)
}
if sc, _ := configuredScope(t, store, pk); sc.String != "#eu" {
t.Errorf("configured_scope = %q, want '#eu' (earlier +02:00 report must not win)", sc.String)
}
// "2026-07-25T15:00:00+02:00" == 13:00Z, chronologically LATER. Must update,
// and be stored canonicalized to UTC.
if err := store.UpdateNodeConfiguredScope(pk, "de", "2026-07-25T15:00:00+02:00"); err != nil {
t.Fatal(err)
}
sc, at := configuredScope(t, store, pk)
if sc.String != "#de" {
t.Errorf("configured_scope = %q, want '#de' (later +02:00 report should win)", sc.String)
}
if at.String != "2026-07-25T13:00:00Z" {
t.Errorf("stored at = %q, want canonical '2026-07-25T13:00:00Z'", at.String)
}
// Firmware's real format (microseconds + "+00:00") canonicalizes to "Z".
if err := store.UpdateNodeConfiguredScope(pk, "dk", "2026-07-26T09:43:48.000000+00:00"); err != nil {
t.Fatal(err)
}
if _, at := configuredScope(t, store, pk); at.String != "2026-07-26T09:43:48Z" {
t.Errorf("stored at = %q, want canonical '2026-07-26T09:43:48Z'", at.String)
}
}