fix: harden GNOME settings recovery
This commit is contained in:
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"proxyctl/internal/gnome"
|
||||
"proxyctl/internal/profile"
|
||||
@@ -89,6 +92,11 @@ func (s *memoryState) Delete() error {
|
||||
s.data = nil
|
||||
return nil
|
||||
}
|
||||
func (s *memoryState) Lock() (io.Closer, error) { return nopCloser{}, nil }
|
||||
|
||||
type nopCloser struct{}
|
||||
|
||||
func (nopCloser) Close() error { return nil }
|
||||
|
||||
func adapter(r *fakeRunner, state *memoryState) *gnome.Adapter {
|
||||
return gnome.New(gnome.Configuration{CommandName: "gsettings", UID: 1000, GID: 1001, HomeDir: "/home/alice", ConfigDir: "/home/alice/.config", DataDir: "/home/alice/.local/share", CacheDir: "/home/alice/.cache", RuntimeDir: "/run/user/1000", StateFile: "/state/gnome.json"}, r, state)
|
||||
@@ -188,6 +196,30 @@ func TestDescriptionStatesDesktopScopeWithoutUniversalCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGVariantSingleAndDoubleQuotedValuesRestoreCanonically(t *testing.T) {
|
||||
r, s := newRunner(), &memoryState{}
|
||||
r.values["org.gnome.system.proxy mode"] = "'auto'"
|
||||
r.values["org.gnome.system.proxy autoconfig-url"] = `"https://config.example/don't\\path"`
|
||||
r.values["org.gnome.system.proxy ignore-hosts"] = `[
|
||||
"don't.example",
|
||||
'back\\slash'
|
||||
]`
|
||||
|
||||
a := adapter(r, s)
|
||||
if got := a.SetDesired(context.Background(), true, active()); got.Err != nil {
|
||||
t.Fatalf("enable: %#v", got)
|
||||
}
|
||||
if got := a.SetDesired(context.Background(), false, nil); got.Err != nil {
|
||||
t.Fatalf("disable: %#v", got)
|
||||
}
|
||||
if got := r.values["org.gnome.system.proxy autoconfig-url"]; got != `"https://config.example/don't\\path"` {
|
||||
t.Fatalf("canonical autoconfig URL = %s", got)
|
||||
}
|
||||
if got := r.values["org.gnome.system.proxy ignore-hosts"]; got != `["don't.example", 'back\\slash']` {
|
||||
t.Fatalf("canonical ignore hosts = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableSnapshotsAppliesInspectsAndRestoresExternalSettings(t *testing.T) {
|
||||
r, s := newRunner(), &memoryState{}
|
||||
r.values["org.gnome.system.proxy mode"] = "'auto'"
|
||||
@@ -309,6 +341,143 @@ func TestCommandContractCarriesInvokingUserIdentityAndSessionEnvironment(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestMidOperationSetFailuresExposePartialStateAndRemainRecoverable(t *testing.T) {
|
||||
t.Run("enable", func(t *testing.T) {
|
||||
r, s := newRunner(), &memoryState{}
|
||||
r.values["org.gnome.system.proxy mode"] = "'auto'"
|
||||
r.values["org.gnome.system.proxy autoconfig-url"] = "'https://old.example/proxy.pac'"
|
||||
setCount := 0
|
||||
r.fail = func(c gnome.Command) error {
|
||||
if len(c.Args) > 0 && c.Args[0] == "set" {
|
||||
setCount++
|
||||
if setCount == 4 {
|
||||
return os.ErrPermission
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
a := adapter(r, s)
|
||||
got := a.SetDesired(context.Background(), true, active())
|
||||
if got.Err == nil || !errors.Is(got.Err, os.ErrPermission) || got.Inspection.State != target.Drifted {
|
||||
t.Fatalf("partial enable = %#v", got)
|
||||
}
|
||||
if setCount != 4 || r.values["org.gnome.system.proxy autoconfig-url"] != "''" || r.values["org.gnome.system.proxy ignore-hosts"] == "[]" || r.values["org.gnome.system.proxy.http host"] != "''" || s.data == nil {
|
||||
t.Fatalf("failure was not genuinely partial: sets=%d values=%#v metadata=%q", setCount, r.values, s.data)
|
||||
}
|
||||
r.fail = nil
|
||||
if got := a.SetDesired(context.Background(), true, active()); got.Err != nil || got.Inspection.State != target.Enabled {
|
||||
t.Fatalf("retry enable = %#v", got)
|
||||
}
|
||||
if got := a.SetDesired(context.Background(), false, nil); got.Err != nil || got.Inspection.State != target.External {
|
||||
t.Fatalf("restore after retry = %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("restore", func(t *testing.T) {
|
||||
r, s := newRunner(), &memoryState{}
|
||||
r.values["org.gnome.system.proxy mode"] = "'auto'"
|
||||
r.values["org.gnome.system.proxy autoconfig-url"] = "'https://old.example/proxy.pac'"
|
||||
a := adapter(r, s)
|
||||
if got := a.SetDesired(context.Background(), true, active()); got.Err != nil {
|
||||
t.Fatal(got.Err)
|
||||
}
|
||||
setCount := 0
|
||||
r.fail = func(c gnome.Command) error {
|
||||
if len(c.Args) > 0 && c.Args[0] == "set" {
|
||||
setCount++
|
||||
if setCount == 5 {
|
||||
return errors.New("session disappeared")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
got := a.SetDesired(context.Background(), false, nil)
|
||||
if got.Err == nil || !strings.Contains(got.Err.Error(), "session disappeared") || got.Inspection.State != target.Drifted || setCount != 5 || s.data == nil {
|
||||
t.Fatalf("partial restore = %#v sets=%d metadata=%q", got, setCount, s.data)
|
||||
}
|
||||
r.fail = nil
|
||||
// A partial restore loses ownership evidence and must not overwrite the
|
||||
// mixed settings on retry.
|
||||
before := len(r.calls)
|
||||
got = a.SetDesired(context.Background(), false, nil)
|
||||
if got.Err == nil || !strings.Contains(got.Err.Error(), "ownership") {
|
||||
t.Fatalf("unsafe retry = %#v", got)
|
||||
}
|
||||
for _, c := range r.calls[before:] {
|
||||
if len(c.Args) > 0 && c.Args[0] == "set" {
|
||||
t.Fatalf("retry mutated partial external state: %#v", c)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRestoreMetadataDeleteFailureCanBeSafelyRetried(t *testing.T) {
|
||||
r, s := newRunner(), &memoryState{}
|
||||
r.values["org.gnome.system.proxy mode"] = "'auto'"
|
||||
r.values["org.gnome.system.proxy autoconfig-url"] = "'https://old.example/proxy.pac'"
|
||||
a := adapter(r, s)
|
||||
if got := a.SetDesired(context.Background(), true, active()); got.Err != nil {
|
||||
t.Fatal(got.Err)
|
||||
}
|
||||
s.deleteErr = os.ErrPermission
|
||||
first := a.SetDesired(context.Background(), false, nil)
|
||||
if first.Err == nil || !errors.Is(first.Err, os.ErrPermission) || r.values["org.gnome.system.proxy mode"] != "'auto'" || s.data == nil {
|
||||
t.Fatalf("first disable = %#v values=%#v metadata=%q", first, r.values, s.data)
|
||||
}
|
||||
s.deleteErr = nil
|
||||
before := len(r.calls)
|
||||
retry := a.SetDesired(context.Background(), false, nil)
|
||||
if retry.Err != nil || retry.Inspection.State != target.External || s.data != nil {
|
||||
t.Fatalf("retry disable = %#v metadata=%q", retry, s.data)
|
||||
}
|
||||
for _, c := range r.calls[before:] {
|
||||
if len(c.Args) > 0 && c.Args[0] == "set" {
|
||||
t.Fatalf("cleanup retry rewrote restored settings: %#v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStateLockSerializesGNOMEOperationsAcrossProcesses(t *testing.T) {
|
||||
if path := os.Getenv("PROXYCTL_GNOME_LOCK_HELPER_PATH"); path != "" {
|
||||
r := newRunner()
|
||||
state := gnome.NewFileState(path, -1, -1, filepath.Dir(filepath.Dir(path)))
|
||||
a := gnome.New(gnome.Configuration{UID: os.Getuid(), GID: os.Getgid(), HomeDir: t.TempDir(), StateFile: path}, r, state)
|
||||
got := a.SetDesired(context.Background(), true, active())
|
||||
if got.Err != nil {
|
||||
t.Fatal(got.Err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "proxyctl", "gnome.json")
|
||||
state := gnome.NewFileState(path, -1, -1, filepath.Dir(filepath.Dir(path)))
|
||||
lock, err := state.Lock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestFileStateLockSerializesGNOMEOperationsAcrossProcesses$")
|
||||
command.Env = append(os.Environ(), "PROXYCTL_GNOME_LOCK_HELPER_PATH="+path)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- command.Run() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("child operation did not wait for lock: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
if err := lock.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("child operation after unlock: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = command.Process.Kill()
|
||||
t.Fatal("child operation remained blocked after unlock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailuresAreImmediateTruthfulAndPreserveOwnership(t *testing.T) {
|
||||
t.Run("permission before mutation", func(t *testing.T) {
|
||||
r, s := newRunner(), &memoryState{}
|
||||
|
||||
Reference in New Issue
Block a user