fix: harden GNOME settings recovery

This commit is contained in:
proxyctl coordinator
2026-07-17 03:41:59 +08:00
parent 72b0a534f6
commit 8aa1fef50b
3 changed files with 347 additions and 51 deletions
+134 -51
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/url"
"os"
@@ -17,6 +18,7 @@ import (
"strconv"
"strings"
"syscall"
"unicode/utf8"
"proxyctl/internal/profile"
"proxyctl/internal/securefs"
@@ -102,6 +104,7 @@ type StateStore interface {
Load() ([]byte, error)
Save([]byte) error
Delete() error
Lock() (io.Closer, error)
}
type fileState struct {
@@ -119,6 +122,9 @@ func NewFileState(path string, uid, gid int, trustedRoot string) StateStore {
func (s *fileState) Load() ([]byte, error) { return s.store.ReadFile(s.path) }
func (s *fileState) Save(data []byte) error { return s.store.AtomicWrite(s.path, data, 0o600) }
func (s *fileState) Delete() error { return s.store.Remove(s.path) }
func (s *fileState) Lock() (io.Closer, error) {
return s.store.LockFile(s.path + ".lock")
}
type Configuration struct {
CommandName string
@@ -322,7 +328,11 @@ func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.
if enabled && active == nil {
return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("no Active Profile; open Profile management to create and activate one")}
}
var err error
lock, err := a.state.Lock()
if err != nil {
return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redactForProfile(fmt.Errorf("lock GNOME mutation: %w", err), active)}
}
defer func() { _ = lock.Close() }() // best effort: release the process-shared advisory lock
if enabled {
err = a.enable(ctx, *active)
} else {
@@ -373,7 +383,17 @@ func (a *Adapter) disable(ctx context.Context) error {
if err != nil {
return fmt.Errorf("verify managed GNOME settings before restore: %w", err)
}
if fingerprint(current) != metadata.LastFingerprint {
currentFingerprint := fingerprint(current)
if currentFingerprint == fingerprint(metadata.Original) {
// The restore completed previously, but metadata cleanup did not. It is
// safe to retry only the cleanup while the restored snapshot still
// matches exactly.
if err := a.state.Delete(); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("remove GNOME recovery metadata after restoring settings: %w", err)
}
return nil
}
if currentFingerprint != metadata.LastFingerprint {
return errors.New("GNOME settings no longer match proxyctl's last applied values; refusing restore because ownership evidence was lost")
}
if err := a.writeSettings(ctx, metadata.Original); err != nil {
@@ -593,7 +613,41 @@ func fingerprint(value settings) string {
}
func quote(value string) string {
return "'" + strings.ReplaceAll(strings.ReplaceAll(value, "\\", "\\\\"), "'", "\\'") + "'"
delimiter := byte('\'')
if strings.ContainsRune(value, '\'') {
// This matches GVariant's canonical printer: apostrophes select a
// double-quoted literal rather than an escaped single-quoted one.
delimiter = '"'
}
var b strings.Builder
b.WriteByte(delimiter)
for _, r := range value {
switch r {
case '\a':
b.WriteString(`\a`)
case '\b':
b.WriteString(`\b`)
case '\f':
b.WriteString(`\f`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
case '\v':
b.WriteString(`\v`)
case '\\':
b.WriteString(`\\`)
default:
if r == rune(delimiter) {
b.WriteByte('\\')
}
b.WriteRune(r)
}
}
b.WriteByte(delimiter)
return b.String()
}
func quoteStrings(values []string) string {
quoted := make([]string, len(values))
@@ -604,74 +658,103 @@ func quoteStrings(values []string) string {
}
func parseString(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if len(raw) < 2 || raw[0] != '\'' || raw[len(raw)-1] != '\'' {
value, consumed, err := parseQuoted(raw)
if err != nil || consumed != len(raw) {
return "", fmt.Errorf("invalid gsettings string %q", raw)
}
var b strings.Builder
escaped := false
for _, r := range raw[1 : len(raw)-1] {
if escaped {
b.WriteRune(r)
escaped = false
} else if r == '\\' {
escaped = true
} else {
b.WriteRune(r)
}
}
if escaped {
return "", fmt.Errorf("invalid gsettings escape %q", raw)
}
return b.String(), nil
return value, nil
}
func parseQuoted(raw string) (string, int, error) {
if len(raw) < 2 || (raw[0] != '\'' && raw[0] != '"') {
return "", 0, errors.New("missing GVariant string delimiter")
}
delimiter := raw[0]
var b strings.Builder
for i := 1; i < len(raw); {
if raw[i] == delimiter {
return b.String(), i + 1, nil
}
if raw[i] != '\\' {
r, size := utf8.DecodeRuneInString(raw[i:])
if r == utf8.RuneError && size == 1 {
return "", 0, errors.New("invalid UTF-8 in GVariant string")
}
b.WriteRune(r)
i += size
continue
}
i++
if i >= len(raw) {
return "", 0, errors.New("trailing GVariant escape")
}
switch raw[i] {
case 'a':
b.WriteByte('\a')
case 'b':
b.WriteByte('\b')
case 'f':
b.WriteByte('\f')
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 't':
b.WriteByte('\t')
case 'v':
b.WriteByte('\v')
case '\\', '\'', '"':
b.WriteByte(raw[i])
case 'u', 'U':
digits := 4
if raw[i] == 'U' {
digits = 8
}
if i+digits >= len(raw) {
return "", 0, errors.New("short GVariant Unicode escape")
}
code, err := strconv.ParseUint(raw[i+1:i+1+digits], 16, 32)
if err != nil || !utf8.ValidRune(rune(code)) {
return "", 0, errors.New("invalid GVariant Unicode escape")
}
b.WriteRune(rune(code))
i += digits
default:
return "", 0, errors.New("unsupported GVariant escape")
}
i++
}
return "", 0, errors.New("unterminated GVariant string")
}
func parseStrings(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "[]" {
return []string{}, nil
}
if len(raw) < 2 || raw[0] != '[' || raw[len(raw)-1] != ']' {
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
}
inner := strings.TrimSpace(raw[1 : len(raw)-1])
if inner == "" {
return []string{}, nil
}
var result []string
for len(inner) > 0 {
if inner[0] != '\'' {
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
}
end := -1
escaped := false
for i := 1; i < len(inner); i++ {
if escaped {
escaped = false
continue
}
if inner[i] == '\\' {
escaped = true
continue
}
if inner[i] == '\'' {
end = i
break
}
}
if end < 0 {
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
}
value, err := parseString(inner[:end+1])
for {
value, consumed, err := parseQuoted(inner)
if err != nil {
return nil, err
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
}
result = append(result, value)
inner = strings.TrimSpace(inner[end+1:])
inner = strings.TrimSpace(inner[consumed:])
if inner == "" {
break
return result, nil
}
if inner[0] != ',' {
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
}
inner = strings.TrimSpace(inner[1:])
if inner == "" {
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
}
}
return result, nil
}
func redactForProfile(err error, active *profile.Profile) error {