From 062b9657c8aa965a7629956a08a6b679aeeec38f Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Wed, 17 Jun 2026 11:43:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(consent):=20add=20consent=20package=20?= =?UTF-8?q?=E2=80=94=20telemetry/broadcasts/reviews=20flags=20(PILOT-392)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces github.com/pilot-protocol/common/consent with GetConsent and SetConsent APIs that read/write the "consent" subkey in ~/.pilot/config.json. All three flags default to true (opt-out model); writes are atomic via fsutil.AtomicWrite (temp-file + rename + dir fsync). Co-Authored-By: Claude Sonnet 4.6 --- consent/consent.go | 135 ++++++++++++++++++++ consent/consent_test.go | 275 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 consent/consent.go create mode 100644 consent/consent_test.go 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) + } + } +}