From 150a78bdde14552cdb57eaba21f865a142bbd5ba Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Thu, 15 Oct 2026 14:29:19 +0330 Subject: [PATCH] feat(state): add runtime state snapshot --- internal/state/state.go | 78 ++++++++++++++++++++++++++++++++++++ internal/state/state_test.go | 65 ++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 internal/state/state.go create mode 100644 internal/state/state_test.go diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..419e701 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT + +// Package state persists the runtime snapshot prx needs to describe and undo +// the currently applied routing configuration. +package state + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "time" + + "prx/internal/fsx" +) + +// State is the on-disk snapshot. +type State struct { + Enabled bool `json:"enabled"` + WANInterface string `json:"wan_interface"` + Gateway4 string `json:"gateway_v4,omitempty"` + Gateway6 string `json:"gateway_v6,omitempty"` + Mark uint32 `json:"mark"` + TableV4 int `json:"table_v4"` + TableV6 int `json:"table_v6"` + PriorityV4 int `json:"priority_v4"` + PriorityV6 int `json:"priority_v6"` + BypassTableV4 int `json:"bypass_table_v4"` + BypassTableV6 int `json:"bypass_table_v6"` + BypassPrioV4 int `json:"bypass_priority_v4"` + BypassPrioV6 int `json:"bypass_priority_v6"` + BypassCIDRs []string `json:"bypass_cidrs,omitempty"` + EngineManaged bool `json:"engine_managed"` + EngineWasRunning bool `json:"engine_was_running"` + EnabledAt time.Time `json:"enabled_at"` + Version string `json:"version,omitempty"` +} + +// Load reads the state. A missing file yields a zero (disabled) state. +// +// The returned state is never nil, even alongside an error, so callers cannot +// dereference a nil pointer after ignoring a read failure. +func Load(path string) (*State, error) { + raw, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &State{}, nil + } + return &State{}, fmt.Errorf("read state %s: %w", path, err) + } + var s State + if err := json.Unmarshal(raw, &s); err != nil { + return &State{}, fmt.Errorf("parse state %s: %w", path, err) + } + return &s, nil +} + +// Save writes the state atomically, creating the parent directory. +func Save(path string, s *State) error { + raw, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("encode state: %w", err) + } + raw = append(raw, '\n') + if err := fsx.WriteFileAtomic(path, raw, 0o644); err != nil { + return fmt.Errorf("write state %s: %w", path, err) + } + return nil +} + +// Remove deletes the state file. A missing file is not an error. +func Remove(path string) error { + err := os.Remove(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 0000000..21dae92 --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT + +package state + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "run", "prx", "state.json") + s := &State{ + Enabled: true, + WANInterface: "wlan0", + Gateway4: "192.168.1.1", + Mark: 0x100, + TableV4: 100, + TableV6: 101, + PriorityV4: 100, + PriorityV6: 100, + } + if err := Save(path, s); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !got.Enabled || got.WANInterface != "wlan0" || got.Mark != 0x100 { + t.Fatalf("round trip mismatch: %+v", got) + } + if err := Remove(path); err != nil { + t.Fatalf("Remove: %v", err) + } + empty, err := Load(path) + if err != nil { + t.Fatalf("Load after remove: %v", err) + } + if empty.Enabled { + t.Fatal("expected disabled state after removal") + } + if err := Remove(path); err != nil { + t.Fatalf("second Remove should be a no-op: %v", err) + } +} + +// TestLoadCorruptNeverReturnsNil guards against the nil-pointer panic that a +// caller ignoring the error would otherwise hit on a corrupt state file. +func TestLoadCorruptNeverReturnsNil(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(path, []byte("{ not json"), 0o644); err != nil { + t.Fatal(err) + } + st, err := Load(path) + if err == nil { + t.Fatal("expected a parse error for a corrupt state file") + } + if st == nil { + t.Fatal("Load must never return a nil state alongside an error") + } + if st.Enabled { + t.Fatal("expected a zero-value state") + } +}