diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-14 11:14:01 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-14 11:14:01 +0300 |
| commit | d5ddf5e7d984351425402064b0e9cb77263dfe03 (patch) | |
| tree | 91e0b1c4b17a56a9a77ca08de10d888644e0bd92 /internal | |
| parent | c20344d0ecbed9f41a4d2e29045ef8b10a249d21 (diff) | |
fix: safe HostAggregate lifespan/downtime and uint64 max boot (ask 34)
Guard Lifespan and Downtime against uint64 underflow when LastSeen < FirstBoot
or Uptime exceeds lifespan. Track per-host max BootTime with uint64 in
LoadAggregates to match file aggregation and avoid int64 conversion bugs.
Tests: lifespan underflow, downtime edge cases, LastKernel near MaxInt64.
Made-with: Cursor
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/goprecords/db.go | 6 | ||||
| -rw-r--r-- | internal/goprecords/db_test.go | 50 | ||||
| -rw-r--r-- | internal/goprecords/types.go | 15 | ||||
| -rw-r--r-- | internal/goprecords/types_test.go | 34 |
4 files changed, 100 insertions, 5 deletions
diff --git a/internal/goprecords/db.go b/internal/goprecords/db.go index 035c13d..904923e 100644 --- a/internal/goprecords/db.go +++ b/internal/goprecords/db.go @@ -42,12 +42,12 @@ func LoadAggregates(ctx context.Context, db *sql.DB) (*Aggregates, error) { KernelMajor: make(map[string]*Aggregate), KernelName: make(map[string]*Aggregate), } - hostMaxBoot := make(map[string]int64) + hostMaxBoot := make(map[string]uint64) hostLastKernel := make(map[string]string) for _, rec := range records { - if rec.BootTime >= uint64(hostMaxBoot[rec.Host]) { - hostMaxBoot[rec.Host] = int64(rec.BootTime) + if rec.BootTime >= hostMaxBoot[rec.Host] { + hostMaxBoot[rec.Host] = rec.BootTime hostLastKernel[rec.Host] = rec.OS } if _, ok := out.Host[rec.Host]; !ok { diff --git a/internal/goprecords/db_test.go b/internal/goprecords/db_test.go index acfe2ca..204d0ca 100644 --- a/internal/goprecords/db_test.go +++ b/internal/goprecords/db_test.go @@ -2,6 +2,7 @@ package goprecords import ( "context" + "math" "os" "path/filepath" "strings" @@ -205,6 +206,55 @@ func TestLoadAggregates(t *testing.T) { if host.Uptime != 3000 { t.Errorf("expected uptime 3000, got %d", host.Uptime) } + if host.LastKernel != "Linux 5.11" { + t.Errorf("LastKernel = %q, want %q (latest boot_time row)", host.LastKernel, "Linux 5.11") + } + } +} + +func TestLoadAggregatesLastKernelMaxBootNearInt64(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + db, err := OpenDB(context.Background(), dbPath) + if err != nil { + t.Fatalf("failed to open DB: %v", err) + } + defer db.Close() + + ctx := context.Background() + if err := CreateSchema(ctx, db); err != nil { + t.Fatalf("failed to create schema: %v", err) + } + + early := math.MaxInt64 - 1 + late := math.MaxInt64 + + _, err = db.ExecContext(ctx, + "INSERT INTO record (host, uptime_sec, boot_time, os, os_kernel_name, os_kernel_major) VALUES (?, ?, ?, ?, ?, ?)", + "host1", 100, early, "Linux early", "Linux", "Linux 5...") + if err != nil { + t.Fatalf("failed to insert: %v", err) + } + + _, err = db.ExecContext(ctx, + "INSERT INTO record (host, uptime_sec, boot_time, os, os_kernel_name, os_kernel_major) VALUES (?, ?, ?, ?, ?, ?)", + "host1", 100, late, "Linux late", "Linux", "Linux 5...") + if err != nil { + t.Fatalf("failed to insert: %v", err) + } + + aggs, err := LoadAggregates(ctx, db) + if err != nil { + t.Fatalf("failed to load aggregates: %v", err) + } + + host := aggs.Host["host1"] + if host == nil { + t.Fatal("expected host1 aggregate") + } + if host.LastKernel != "Linux late" { + t.Errorf("LastKernel = %q, want %q", host.LastKernel, "Linux late") } } diff --git a/internal/goprecords/types.go b/internal/goprecords/types.go index 6e5bf10..0f9c3a3 100644 --- a/internal/goprecords/types.go +++ b/internal/goprecords/types.go @@ -177,10 +177,21 @@ func (a *Aggregate) MetaScore() uint64 { } // Lifespan returns last-seen minus first-boot. -func (h *HostAggregate) Lifespan() uint64 { return h.LastSeen - h.FirstBoot } +func (h *HostAggregate) Lifespan() uint64 { + if h.LastSeen < h.FirstBoot { + return 0 + } + return h.LastSeen - h.FirstBoot +} // Downtime returns lifespan minus uptime. -func (h *HostAggregate) Downtime() uint64 { return h.Lifespan() - h.Uptime } +func (h *HostAggregate) Downtime() uint64 { + life := h.Lifespan() + if h.Uptime > life { + return 0 + } + return life - h.Uptime +} // MetaScore returns the host-specific score (includes downtime component). func (h *HostAggregate) MetaScore() uint64 { diff --git a/internal/goprecords/types_test.go b/internal/goprecords/types_test.go index a315784..245cead 100644 --- a/internal/goprecords/types_test.go +++ b/internal/goprecords/types_test.go @@ -113,6 +113,40 @@ func TestHostAggregateDowntime(t *testing.T) { } } +func TestHostAggregateLifespanUnderflow(t *testing.T) { + hagg := NewHostAggregate("host1", "Linux 5.10") + hagg.FirstBoot = 5000 + hagg.LastSeen = 1000 + + if got := hagg.Lifespan(); got != 0 { + t.Errorf("Lifespan() = %d, want 0 when LastSeen < FirstBoot", got) + } +} + +func TestHostAggregateDowntimeUnderflow(t *testing.T) { + tests := []struct { + name string + firstBoot uint64 + lastSeen uint64 + uptime uint64 + }{ + {"uptime equals lifespan", 1000, 5000, 4000}, + {"uptime exceeds lifespan", 1000, 5000, 5000}, + {"uptime exceeds short span", 0, 100, 200}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hagg := NewHostAggregate("host1", "Linux 5.10") + hagg.FirstBoot = tt.firstBoot + hagg.LastSeen = tt.lastSeen + hagg.Uptime = tt.uptime + if got := hagg.Downtime(); got != 0 { + t.Errorf("Downtime() = %d, want 0", got) + } + }) + } +} + func TestEpochHumanDuration(t *testing.T) { // Unix epoch + 1 year + 2 months + 3 days epoch := Epoch(31536000 + (60 * 24 * 3600) + (3 * 24 * 3600)) |
