fix: harden GNOME settings recovery
This commit is contained in:
+134
-51
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user