diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f1b219b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" diff --git a/consent/consent.go b/consent/consent.go new file mode 100644 index 0000000..1694285 --- /dev/null +++ b/consent/consent.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package consent provides opt-out consent flags stored in ~/.pilot/config.json. +// +// Three flags are recognised: "telemetry", "broadcasts", and "reviews". +// All flags default to true (opt-out model) when absent from the config file or +// when the config file does not exist yet. +// +// The config file format is: +// +// {"consent": {"telemetry": true, "broadcasts": true, "reviews": false}} +// +// Writes are atomic: the package reads the existing file, updates only the +// "consent" subkey, and writes back via a temp-file + rename so the file is +// never left in a partial state on crash. +package consent + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/pilot-protocol/common/fsutil" +) + +// validFlags is the set of flag names the package recognises. +var validFlags = map[string]struct{}{ + "telemetry": {}, + "broadcasts": {}, + "reviews": {}, +} + +// configPath returns the path to ~/.pilot/config.json given the user's home +// directory. Callers pass home so the function is testable without touching +// the real home directory. +func configPath(home string) string { + return filepath.Join(home, ".pilot", "config.json") +} + +// readRaw reads and JSON-decodes ~/.pilot/config.json into a generic map. +// If the file does not exist an empty (non-nil) map is returned so callers +// can treat absent-file the same as empty-file. +func readRaw(home string) (map[string]interface{}, error) { + path := configPath(home) + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return map[string]interface{}{}, nil + } + if err != nil { + return nil, fmt.Errorf("consent: read config: %w", err) + } + var root map[string]interface{} + if err := json.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("consent: parse config: %w", err) + } + return root, nil +} + +// consentMap extracts the "consent" submap from root, returning an empty +// (non-nil) map when the subkey is absent or has the wrong type. +func consentMap(root map[string]interface{}) map[string]interface{} { + if v, ok := root["consent"]; ok { + if m, ok := v.(map[string]interface{}); ok { + return m + } + } + return map[string]interface{}{} +} + +// GetConsent returns the consent value for flag ("telemetry", "broadcasts", +// "reviews"). It defaults to true (opt-out model) when the flag is absent +// from the config file, or when the config file does not exist yet. Unknown +// flag names also return true so that callers which don't validate the flag +// name beforehand are safe — use SetConsent to get an error on invalid names. +func GetConsent(home, flag string) bool { + root, err := readRaw(home) + if err != nil { + // On read/parse error fall back to the safe default. + return true + } + cm := consentMap(root) + v, ok := cm[flag] + if !ok { + return true // absent → default true + } + b, ok := v.(bool) + if !ok { + return true // malformed entry → default true + } + return b +} + +// SetConsent persists one consent flag. It reads the existing config, +// updates only the consent subkey for the named flag, and writes back +// atomically. The parent directory (~/.pilot) is created if it does not +// exist yet. +// +// flag must be one of "telemetry", "broadcasts", or "reviews"; any other +// value returns a descriptive error and leaves the config file unchanged. +func SetConsent(home, flag string, value bool) error { + if _, ok := validFlags[flag]; !ok { + return fmt.Errorf("consent: unknown flag %q: must be one of telemetry, broadcasts, reviews", flag) + } + + root, err := readRaw(home) + if err != nil { + return err + } + + // Copy the existing consent map and set the new value. + cm := consentMap(root) + updated := make(map[string]interface{}, len(cm)+1) + for k, v := range cm { + updated[k] = v + } + updated[flag] = value + root["consent"] = updated + + data, err := json.MarshalIndent(root, "", " ") + if err != nil { + return fmt.Errorf("consent: marshal config: %w", err) + } + + path := configPath(home) + // Ensure ~/.pilot exists before trying to write. + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("consent: create config dir: %w", err) + } + + if err := fsutil.AtomicWrite(path, data); err != nil { + return fmt.Errorf("consent: write config: %w", err) + } + return nil +} diff --git a/consent/consent_test.go b/consent/consent_test.go new file mode 100644 index 0000000..c7f90ab --- /dev/null +++ b/consent/consent_test.go @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package consent_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/pilot-protocol/common/consent" +) + +// writeConfig is a test helper that writes raw JSON to ~/.pilot/config.json +// inside a temp home directory. +func writeConfig(t *testing.T, home, body string) { + t.Helper() + dir := filepath.Join(home, ".pilot") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir .pilot: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(body), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } +} + +// readConfig is a test helper that reads ~/.pilot/config.json from a temp +// home directory and decodes it into a generic map. +func readConfig(t *testing.T, home string) map[string]interface{} { + t.Helper() + data, err := os.ReadFile(filepath.Join(home, ".pilot", "config.json")) + if err != nil { + t.Fatalf("read config: %v", err) + } + var root map[string]interface{} + if err := json.Unmarshal(data, &root); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + return root +} + +// --- GetConsent --- + +func TestGetConsent_AbsentFile_DefaultsTrue(t *testing.T) { + t.Parallel() + home := t.TempDir() + for _, flag := range []string{"telemetry", "broadcasts", "reviews"} { + if got := consent.GetConsent(home, flag); !got { + t.Errorf("GetConsent(%q) = false, want true (absent file → default true)", flag) + } + } +} + +func TestGetConsent_AbsentKey_DefaultsTrue(t *testing.T) { + t.Parallel() + home := t.TempDir() + // Config exists but has no "consent" subkey. + writeConfig(t, home, `{"other":"value"}`) + for _, flag := range []string{"telemetry", "broadcasts", "reviews"} { + if got := consent.GetConsent(home, flag); !got { + t.Errorf("GetConsent(%q) = false, want true (absent key → default true)", flag) + } + } +} + +func TestGetConsent_AbsentFlagWithinConsentMap_DefaultsTrue(t *testing.T) { + t.Parallel() + home := t.TempDir() + // Consent object exists but the specific flag is absent. + writeConfig(t, home, `{"consent":{"telemetry":false}}`) + if got := consent.GetConsent(home, "broadcasts"); !got { + t.Error("GetConsent(broadcasts) = false, want true (absent within consent map → default true)") + } +} + +func TestGetConsent_ReadsStoredFalse(t *testing.T) { + t.Parallel() + home := t.TempDir() + writeConfig(t, home, `{"consent":{"telemetry":false,"broadcasts":true,"reviews":false}}`) + if got := consent.GetConsent(home, "telemetry"); got { + t.Error("GetConsent(telemetry) = true, want false") + } + if got := consent.GetConsent(home, "broadcasts"); !got { + t.Error("GetConsent(broadcasts) = false, want true") + } + if got := consent.GetConsent(home, "reviews"); got { + t.Error("GetConsent(reviews) = true, want false") + } +} + +func TestGetConsent_MalformedConsentValue_DefaultsTrue(t *testing.T) { + t.Parallel() + home := t.TempDir() + // The flag value is a string, not a bool. + writeConfig(t, home, `{"consent":{"telemetry":"yes"}}`) + if got := consent.GetConsent(home, "telemetry"); !got { + t.Error("GetConsent(telemetry) = false, want true (malformed value → default true)") + } +} + +func TestGetConsent_UnknownFlag_DefaultsTrue(t *testing.T) { + t.Parallel() + home := t.TempDir() + writeConfig(t, home, `{"consent":{"telemetry":false}}`) + // Unknown flag should default to true, not leak the telemetry value. + if got := consent.GetConsent(home, "unknown-flag"); !got { + t.Error("GetConsent(unknown-flag) = false, want true (unknown flag → default true)") + } +} + +// --- SetConsent --- + +func TestSetConsent_InvalidFlag_ReturnsError(t *testing.T) { + t.Parallel() + home := t.TempDir() + err := consent.SetConsent(home, "invalid_flag", true) + if err == nil { + t.Fatal("SetConsent(invalid_flag) expected error, got nil") + } +} + +func TestSetConsent_InvalidFlag_DoesNotCreateFile(t *testing.T) { + t.Parallel() + home := t.TempDir() + _ = consent.SetConsent(home, "invalid_flag", true) + if _, err := os.Stat(filepath.Join(home, ".pilot", "config.json")); !os.IsNotExist(err) { + t.Error("config.json should not be created for invalid flag") + } +} + +func TestSetConsent_CreatesFileAndDir(t *testing.T) { + t.Parallel() + home := t.TempDir() + // ~/.pilot does not exist yet. + if err := consent.SetConsent(home, "telemetry", false); err != nil { + t.Fatalf("SetConsent: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".pilot", "config.json")); err != nil { + t.Fatalf("config.json not created: %v", err) + } +} + +func TestSetConsent_SetFalseReadBackFalse(t *testing.T) { + t.Parallel() + home := t.TempDir() + if err := consent.SetConsent(home, "telemetry", false); err != nil { + t.Fatalf("SetConsent: %v", err) + } + if got := consent.GetConsent(home, "telemetry"); got { + t.Error("GetConsent(telemetry) = true, want false after SetConsent(false)") + } +} + +func TestSetConsent_SetFalseThenTrueReadBackTrue(t *testing.T) { + t.Parallel() + home := t.TempDir() + if err := consent.SetConsent(home, "reviews", false); err != nil { + t.Fatalf("SetConsent(false): %v", err) + } + if err := consent.SetConsent(home, "reviews", true); err != nil { + t.Fatalf("SetConsent(true): %v", err) + } + if got := consent.GetConsent(home, "reviews"); !got { + t.Error("GetConsent(reviews) = false, want true after round-trip false→true") + } +} + +func TestSetConsent_OneFlagDoesNotAffectOthers(t *testing.T) { + t.Parallel() + home := t.TempDir() + // Set telemetry=false; broadcasts and reviews must still return true. + if err := consent.SetConsent(home, "telemetry", false); err != nil { + t.Fatalf("SetConsent: %v", err) + } + if got := consent.GetConsent(home, "broadcasts"); !got { + t.Error("GetConsent(broadcasts) = false, want true (unset flag must default true)") + } + if got := consent.GetConsent(home, "reviews"); !got { + t.Error("GetConsent(reviews) = false, want true (unset flag must default true)") + } +} + +func TestSetConsent_PreservesOtherTopLevelKeys(t *testing.T) { + t.Parallel() + home := t.TempDir() + // Pre-populate config with a non-consent key. + writeConfig(t, home, `{"other_key":"kept","consent":{"broadcasts":false}}`) + if err := consent.SetConsent(home, "telemetry", false); err != nil { + t.Fatalf("SetConsent: %v", err) + } + root := readConfig(t, home) + if root["other_key"] != "kept" { + t.Errorf("other_key = %v, want 'kept' (SetConsent must not drop unrelated keys)", root["other_key"]) + } +} + +func TestSetConsent_PreservesExistingConsentFlags(t *testing.T) { + t.Parallel() + home := t.TempDir() + writeConfig(t, home, `{"consent":{"telemetry":false,"broadcasts":false,"reviews":true}}`) + // Update only broadcasts. + if err := consent.SetConsent(home, "broadcasts", true); err != nil { + t.Fatalf("SetConsent: %v", err) + } + root := readConfig(t, home) + cm := root["consent"].(map[string]interface{}) + if cm["telemetry"] != false { + t.Errorf("telemetry = %v, want false (must be unchanged)", cm["telemetry"]) + } + if cm["broadcasts"] != true { + t.Errorf("broadcasts = %v, want true (just set)", cm["broadcasts"]) + } + if cm["reviews"] != true { + t.Errorf("reviews = %v, want true (must be unchanged)", cm["reviews"]) + } +} + +func TestSetConsent_AtomicWrite_NoTmpFile(t *testing.T) { + t.Parallel() + home := t.TempDir() + if err := consent.SetConsent(home, "telemetry", false); err != nil { + t.Fatalf("SetConsent: %v", err) + } + // After a successful write the .tmp file must be gone (renamed away). + tmpPath := filepath.Join(home, ".pilot", "config.json.tmp") + if _, err := os.Stat(tmpPath); !os.IsNotExist(err) { + t.Error("config.json.tmp should not exist after successful SetConsent") + } +} + +func TestSetConsent_AllThreeFlagsIndependently(t *testing.T) { + t.Parallel() + home := t.TempDir() + flags := []string{"telemetry", "broadcasts", "reviews"} + // Set all to false one by one; verify each after each write. + for _, flag := range flags { + if err := consent.SetConsent(home, flag, false); err != nil { + t.Fatalf("SetConsent(%s, false): %v", flag, err) + } + if got := consent.GetConsent(home, flag); got { + t.Errorf("GetConsent(%s) = true after SetConsent(false)", flag) + } + } + // Now set all back to true. + for _, flag := range flags { + if err := consent.SetConsent(home, flag, true); err != nil { + t.Fatalf("SetConsent(%s, true): %v", flag, err) + } + if got := consent.GetConsent(home, flag); !got { + t.Errorf("GetConsent(%s) = false after SetConsent(true)", flag) + } + } +} + +func TestSetConsent_InvalidFlagErrorMessage(t *testing.T) { + t.Parallel() + home := t.TempDir() + err := consent.SetConsent(home, "badname", false) + if err == nil { + t.Fatal("expected error for unknown flag") + } + msg := err.Error() + for _, want := range []string{"badname", "telemetry", "broadcasts", "reviews"} { + found := false + for i := 0; i+len(want) <= len(msg); i++ { + if msg[i:i+len(want)] == want { + found = true + break + } + } + if !found { + t.Errorf("error message %q missing word %q", msg, want) + } + } +} diff --git a/protocol/header.go b/protocol/header.go index edc3dd1..3abf7ce 100644 --- a/protocol/header.go +++ b/protocol/header.go @@ -84,4 +84,14 @@ const ( BeaconMsgRelay byte = 0x05 BeaconMsgRelayDeliver byte = 0x06 BeaconMsgSync byte = 0x07 // gossip: beacon-to-beacon node list exchange + + // Extended discovery for endpoints that cannot infer a relayed frame's + // destination implicitly. A standard endpoint owns a single node id, so + // BeaconMsgRelayDeliver (0x06 = [0x06][srcNodeID(4)][frame]) carries no + // destination. An endpoint registered via BeaconMsgDiscoverEx (same wire + // shape as BeaconMsgDiscover: [0x08][nodeID(4)]) instead receives + // BeaconMsgRelayDeliverDest = [0x09][srcNodeID(4)][destNodeID(4)][frame...]. + // Opt-in: endpoints registered with 0x01 keep receiving 0x06. + BeaconMsgDiscoverEx byte = 0x08 + BeaconMsgRelayDeliverDest byte = 0x09 )