Files
proxy/internal/gnome/gnome.go
T

804 lines
26 KiB
Go

// Package gnome implements the GNOME desktop proxy Target through gsettings.
package gnome
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"unicode/utf8"
"proxyctl/internal/profile"
"proxyctl/internal/securefs"
"proxyctl/internal/target"
)
const (
proxySchema = "org.gnome.system.proxy"
httpSchema = proxySchema + ".http"
httpsSchema = proxySchema + ".https"
socksSchema = proxySchema + ".socks"
)
var requiredSchemas = []string{proxySchema, httpSchema, httpsSchema, socksSchema}
// Command is the complete command contract used by the adapter. UID, GID, and
// Env make user-session identity explicit when proxyctl itself runs under sudo.
type Command struct {
Name string
Args []string
UID int
GID int
Env map[string]string
}
type CommandRunner interface {
Run(context.Context, Command) (string, error)
}
type execRunner struct{}
func (execRunner) Run(ctx context.Context, command Command) (string, error) {
cmd := exec.CommandContext(ctx, command.Name, command.Args...)
environment := envWithOverrides(os.Environ(), command.Env)
cmd.Env = environment
if os.Geteuid() != command.UID {
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uint32(command.UID), Gid: uint32(command.GID)}}
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
detail := strings.TrimSpace(stderr.String())
if detail == "" {
return "", fmt.Errorf("run %s: %w", command.Name, err)
}
if strings.Contains(strings.ToLower(detail), "permission denied") {
return "", fmt.Errorf("run %s: %s: %w", command.Name, detail, os.ErrPermission)
}
return "", fmt.Errorf("run %s: %s: %w", command.Name, detail, err)
}
return string(output), nil
}
func envWithOverrides(base []string, overrides map[string]string) []string {
values := make(map[string]string, len(base)+len(overrides))
order := make([]string, 0, len(base)+len(overrides))
for _, item := range base {
name, value, ok := strings.Cut(item, "=")
if !ok {
continue
}
if _, exists := values[name]; !exists {
order = append(order, name)
}
values[name] = value
}
for name, value := range overrides {
if _, exists := values[name]; !exists {
order = append(order, name)
}
values[name] = value
}
result := make([]string, 0, len(order))
for _, name := range order {
result = append(result, name+"="+values[name])
}
return result
}
// StateStore holds the original GNOME values and last managed fingerprint.
type StateStore interface {
Load() ([]byte, error)
Save([]byte) error
Delete() error
Lock() (io.Closer, error)
}
type fileState struct {
path string
store securefs.Store
}
func NewFileState(path string, uid, gid int, trustedRoot string) StateStore {
var owner *securefs.Owner
if uid >= 0 && gid >= 0 {
owner = &securefs.Owner{UID: uid, GID: gid}
}
return &fileState{path: path, store: securefs.Store{TrustedRoot: trustedRoot, Owner: owner}}
}
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
UID int
GID int
Username string
HomeDir string
ConfigDir string
DataDir string
CacheDir string
RuntimeDir string
SessionBus string
StateFile string
TrustedRoot string
}
// ResolveConfiguration keeps both recovery state and gsettings commands with
// the invoking user under sudo, never root's unrelated desktop session.
func ResolveConfiguration(environment profile.IdentityEnvironment) (Configuration, error) {
profileConfiguration, err := profile.ResolveConfiguration(environment)
if err != nil {
return Configuration{}, err
}
owner := profileConfiguration.Owner
current, err := environment.CurrentUser()
if err != nil {
return Configuration{}, fmt.Errorf("identify effective user: %w", err)
}
invoking := current.Username != owner.Username
stateDir := ""
if value, ok := environment.LookupEnv("XDG_STATE_HOME"); ok && filepath.IsAbs(value) && (!invoking || pathWithin(value, owner.HomeDir)) {
stateDir = value
}
if stateDir == "" {
stateDir = filepath.Join(owner.HomeDir, ".local", "state")
}
configDir := filepath.Dir(profileConfiguration.Path)
dataDir := filepath.Join(owner.HomeDir, ".local", "share")
if value, ok := environment.LookupEnv("XDG_DATA_HOME"); ok && filepath.IsAbs(value) && (!invoking || pathWithin(value, owner.HomeDir)) {
dataDir = value
}
cacheDir := filepath.Join(owner.HomeDir, ".cache")
if value, ok := environment.LookupEnv("XDG_CACHE_HOME"); ok && filepath.IsAbs(value) && (!invoking || pathWithin(value, owner.HomeDir)) {
cacheDir = value
}
runtimeDir := filepath.Join("/run/user", strconv.Itoa(owner.UID))
if value, ok := environment.LookupEnv("XDG_RUNTIME_DIR"); ok && filepath.IsAbs(value) && !invoking {
runtimeDir = value
}
sessionBus := "unix:path=" + filepath.Join(runtimeDir, "bus")
if value, ok := environment.LookupEnv("DBUS_SESSION_BUS_ADDRESS"); ok && value != "" && !invoking {
sessionBus = value
}
trustedRoot := owner.HomeDir
if !pathWithin(stateDir, owner.HomeDir) {
trustedRoot = stateDir
}
return Configuration{CommandName: "gsettings", UID: owner.UID, GID: owner.GID, Username: owner.Username, HomeDir: owner.HomeDir, ConfigDir: configDir, DataDir: dataDir, CacheDir: cacheDir, RuntimeDir: runtimeDir, SessionBus: sessionBus, StateFile: filepath.Join(stateDir, "proxyctl", "gnome.json"), TrustedRoot: trustedRoot}, nil
}
func ResolveConfigurationForCurrentProcess() (Configuration, error) {
return ResolveConfiguration(systemEnvironment{})
}
type systemEnvironment struct{}
func (systemEnvironment) CurrentUser() (profile.UserIdentity, error) {
return profile.SystemCurrentUser()
}
func (systemEnvironment) LookupUser(name string) (profile.UserIdentity, error) {
return profile.SystemLookupUser(name)
}
func (systemEnvironment) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) }
func pathWithin(path, parent string) bool {
relative, err := filepath.Rel(parent, path)
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
type settings struct {
Mode string `json:"mode"`
AutoConfigURL string `json:"autoConfigUrl"`
IgnoreHosts []string `json:"ignoreHosts"`
HTTPHost string `json:"httpHost"`
HTTPPort int `json:"httpPort"`
HTTPUseAuth bool `json:"httpUseAuthentication"`
HTTPAuthUser string `json:"httpAuthenticationUser"`
HTTPAuthPassword string `json:"httpAuthenticationPassword"`
HTTPSHost string `json:"httpsHost"`
HTTPSPort int `json:"httpsPort"`
SocksHost string `json:"socksHost"`
SocksPort int `json:"socksPort"`
}
type recovery struct {
Original settings `json:"original"`
LastFingerprint string `json:"lastFingerprint"`
}
type Adapter struct {
configuration Configuration
runner CommandRunner
state StateStore
}
var _ target.Target = (*Adapter)(nil)
func New(configuration Configuration, runner CommandRunner, state StateStore) *Adapter {
if configuration.CommandName == "" {
configuration.CommandName = "gsettings"
}
if configuration.ConfigDir == "" {
configuration.ConfigDir = filepath.Join(configuration.HomeDir, ".config")
}
if configuration.DataDir == "" {
configuration.DataDir = filepath.Join(configuration.HomeDir, ".local", "share")
}
if configuration.CacheDir == "" {
configuration.CacheDir = filepath.Join(configuration.HomeDir, ".cache")
}
if configuration.RuntimeDir == "" {
configuration.RuntimeDir = filepath.Join("/run/user", strconv.Itoa(configuration.UID))
}
if configuration.SessionBus == "" {
configuration.SessionBus = "unix:path=" + filepath.Join(configuration.RuntimeDir, "bus")
}
if runner == nil {
runner = execRunner{}
}
if state == nil {
state = NewFileState(configuration.StateFile, configuration.UID, configuration.GID, configuration.TrustedRoot)
}
return &Adapter{configuration: configuration, runner: runner, state: state}
}
func (a *Adapter) ID() string { return "gnome" }
func (a *Adapter) Description() target.Description {
return target.Description{
Name: "GNOME Desktop",
Summary: "Configures the GNOME desktop system proxy.",
Details: "Affects applications that consume GNOME system network settings; not every graphical application consumes these settings. Settings belong to the desktop user and require that user's session bus and schema permissions.",
}
}
func (a *Adapter) Availability(ctx context.Context) target.Availability {
output, err := a.run(ctx, "list-schemas")
if err != nil {
return target.Availability{Evidence: fmt.Sprintf("GNOME Target requires a usable %s command: %v", a.configuration.CommandName, redact(err))}
}
present := make(map[string]bool)
for _, schema := range strings.Fields(output) {
present[schema] = true
}
for _, schema := range requiredSchemas {
if !present[schema] {
return target.Availability{Evidence: "required GNOME settings schema is unavailable: " + schema}
}
}
if _, err := a.readSettings(ctx); err != nil {
return target.Availability{Evidence: fmt.Sprintf("GNOME proxy schema is present but unusable for the desktop session: %v", redact(err))}
}
return target.Availability{Available: true, Evidence: "gsettings command and GNOME proxy schemas are usable for the owning user"}
}
func (a *Adapter) Inspect(ctx context.Context, active *profile.Profile) target.Inspection {
metadata, managed, metadataErr := a.readRecovery()
current, settingsErr := a.readSettings(ctx)
if managed || (metadataErr != nil && !errors.Is(metadataErr, fs.ErrNotExist)) {
if metadataErr != nil {
return target.Inspection{State: target.Drifted, Evidence: "managed GNOME recovery metadata cannot be read"}
}
if settingsErr != nil {
return target.Inspection{State: target.Drifted, Evidence: "managed GNOME settings cannot be read: " + redact(settingsErr).Error()}
}
if fingerprint(current) == metadata.LastFingerprint && active != nil {
desired, err := translate(*active)
if err == nil && fingerprint(desired) == metadata.LastFingerprint {
return target.Inspection{State: target.Enabled, Evidence: "managed GNOME settings match the Active Profile"}
}
}
return target.Inspection{State: target.Drifted, Evidence: "managed GNOME settings do not match the Active Profile"}
}
if settingsErr != nil {
return target.Inspection{State: target.Disabled, Evidence: "GNOME settings could not be inspected: " + redact(settingsErr).Error()}
}
switch current.Mode {
case "manual":
return target.Inspection{State: target.External, Evidence: "recognized external GNOME manual proxy mode is configured"}
case "auto":
return target.Inspection{State: target.External, Evidence: "recognized external GNOME automatic proxy mode is configured"}
default:
return target.Inspection{State: target.Disabled, Evidence: "GNOME proxy mode is none; no recognized desktop proxy configuration detected"}
}
}
func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.Profile) target.OperationResult {
availability := a.Availability(ctx)
if !availability.Available {
return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("GNOME Target is unavailable: " + availability.Evidence)}
}
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")}
}
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 {
err = a.disable(ctx)
}
return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: redactForProfile(err, active)}
}
func (a *Adapter) enable(ctx context.Context, active profile.Profile) error {
desired, err := translate(active)
if err != nil {
return err
}
metadata, managed, err := a.readRecovery()
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("read GNOME recovery metadata: %w", err)
}
if !managed {
original, err := a.readSettings(ctx)
if err != nil {
return fmt.Errorf("snapshot original GNOME settings: %w", err)
}
metadata.Original = original
}
metadata.LastFingerprint = fingerprint(desired)
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return err
}
if err := a.state.Save(append(data, '\n')); err != nil {
return fmt.Errorf("persist GNOME recovery metadata before changing settings: %w", err)
}
if err := a.writeSettings(ctx, desired); err != nil {
return fmt.Errorf("apply GNOME proxy settings after recovery metadata persisted: %w", err)
}
return nil
}
func (a *Adapter) disable(ctx context.Context) error {
metadata, managed, err := a.readRecovery()
if errors.Is(err, fs.ErrNotExist) || !managed {
return nil
}
if err != nil {
return fmt.Errorf("read GNOME recovery metadata: %w", err)
}
current, err := a.readSettings(ctx)
if err != nil {
return fmt.Errorf("verify managed GNOME settings before restore: %w", err)
}
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 {
return fmt.Errorf("restore original GNOME settings: %w", err)
}
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
}
func (a *Adapter) readRecovery() (recovery, bool, error) {
data, err := a.state.Load()
if err != nil {
return recovery{}, false, err
}
var value recovery
if err := json.Unmarshal(data, &value); err != nil {
return recovery{}, true, err
}
return value, true, nil
}
func (a *Adapter) command(args ...string) Command {
return Command{Name: a.configuration.CommandName, Args: append([]string(nil), args...), UID: a.configuration.UID, GID: a.configuration.GID, Env: map[string]string{
"HOME": a.configuration.HomeDir, "USER": a.configuration.Username, "LOGNAME": a.configuration.Username,
"XDG_CONFIG_HOME": a.configuration.ConfigDir, "XDG_DATA_HOME": a.configuration.DataDir, "XDG_CACHE_HOME": a.configuration.CacheDir,
"XDG_RUNTIME_DIR": a.configuration.RuntimeDir, "DBUS_SESSION_BUS_ADDRESS": a.configuration.SessionBus,
}}
}
func (a *Adapter) run(ctx context.Context, args ...string) (string, error) {
return a.runner.Run(ctx, a.command(args...))
}
func (a *Adapter) get(ctx context.Context, schema, key string) (string, error) {
value, err := a.run(ctx, "get", schema, key)
if err != nil {
return "", fmt.Errorf("get %s %s: %w", schema, key, err)
}
return strings.TrimSpace(value), nil
}
func (a *Adapter) set(ctx context.Context, schema, key, value string) error {
if _, err := a.run(ctx, "set", schema, key, value); err != nil {
return fmt.Errorf("set %s %s: %w", schema, key, err)
}
return nil
}
func (a *Adapter) readSettings(ctx context.Context) (settings, error) {
var result settings
var err error
if result.Mode, err = a.getString(ctx, proxySchema, "mode"); err != nil {
return settings{}, err
}
if result.AutoConfigURL, err = a.getString(ctx, proxySchema, "autoconfig-url"); err != nil {
return settings{}, err
}
if result.IgnoreHosts, err = a.getStrings(ctx, proxySchema, "ignore-hosts"); err != nil {
return settings{}, err
}
if result.HTTPHost, err = a.getString(ctx, httpSchema, "host"); err != nil {
return settings{}, err
}
if result.HTTPPort, err = a.getInt(ctx, httpSchema, "port"); err != nil {
return settings{}, err
}
if result.HTTPUseAuth, err = a.getBool(ctx, httpSchema, "use-authentication"); err != nil {
return settings{}, err
}
if result.HTTPAuthUser, err = a.getString(ctx, httpSchema, "authentication-user"); err != nil {
return settings{}, err
}
if result.HTTPAuthPassword, err = a.getString(ctx, httpSchema, "authentication-password"); err != nil {
return settings{}, err
}
if result.HTTPSHost, err = a.getString(ctx, httpsSchema, "host"); err != nil {
return settings{}, err
}
if result.HTTPSPort, err = a.getInt(ctx, httpsSchema, "port"); err != nil {
return settings{}, err
}
if result.SocksHost, err = a.getString(ctx, socksSchema, "host"); err != nil {
return settings{}, err
}
if result.SocksPort, err = a.getInt(ctx, socksSchema, "port"); err != nil {
return settings{}, err
}
return result, nil
}
func (a *Adapter) getString(ctx context.Context, schema, key string) (string, error) {
raw, err := a.get(ctx, schema, key)
if err != nil {
return "", err
}
return parseString(raw)
}
func (a *Adapter) getInt(ctx context.Context, schema, key string) (int, error) {
raw, err := a.get(ctx, schema, key)
if err != nil {
return 0, err
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("parse %s %s integer %q: %w", schema, key, raw, err)
}
return value, nil
}
func (a *Adapter) getBool(ctx context.Context, schema, key string) (bool, error) {
raw, err := a.get(ctx, schema, key)
if err != nil {
return false, err
}
value, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("parse %s %s boolean %q: %w", schema, key, raw, err)
}
return value, nil
}
func (a *Adapter) getStrings(ctx context.Context, schema, key string) ([]string, error) {
raw, err := a.get(ctx, schema, key)
if err != nil {
return nil, err
}
return parseStrings(raw)
}
func (a *Adapter) writeSettings(ctx context.Context, value settings) error {
// Keep manual mode from becoming active until all component values exist.
if err := a.set(ctx, proxySchema, "mode", quote("none")); err != nil {
return err
}
operations := []struct{ schema, key, value string }{
{proxySchema, "autoconfig-url", quote(value.AutoConfigURL)},
{proxySchema, "ignore-hosts", quoteStrings(value.IgnoreHosts)},
{httpSchema, "host", quote(value.HTTPHost)}, {httpSchema, "port", strconv.Itoa(value.HTTPPort)},
{httpSchema, "use-authentication", strconv.FormatBool(value.HTTPUseAuth)}, {httpSchema, "authentication-user", quote(value.HTTPAuthUser)}, {httpSchema, "authentication-password", quote(value.HTTPAuthPassword)},
{httpsSchema, "host", quote(value.HTTPSHost)}, {httpsSchema, "port", strconv.Itoa(value.HTTPSPort)},
{socksSchema, "host", quote(value.SocksHost)}, {socksSchema, "port", strconv.Itoa(value.SocksPort)},
}
for _, operation := range operations {
if err := a.set(ctx, operation.schema, operation.key, operation.value); err != nil {
return err
}
}
return a.set(ctx, proxySchema, "mode", quote(value.Mode))
}
func translate(input profile.Profile) (settings, error) {
p, err := profile.ValidateAndNormalize(input)
if err != nil {
return settings{}, err
}
result := settings{Mode: "manual"}
if p.Mode == profile.SingleMode {
if result.SocksHost, result.SocksPort, _, _, err = endpoint(p.SingleURL, "Single proxy URL", "socks5", false); err != nil {
return settings{}, err
}
for _, bypass := range p.Bypass {
if strings.HasPrefix(bypass, ".") {
result.IgnoreHosts = append(result.IgnoreHosts, "*"+bypass)
} else {
result.IgnoreHosts = append(result.IgnoreHosts, bypass)
}
}
return result, nil
}
if result.HTTPHost, result.HTTPPort, result.HTTPAuthUser, result.HTTPAuthPassword, err = endpoint(p.HTTPURL, "HTTP proxy URL", "http", true); err != nil {
return settings{}, err
}
result.HTTPUseAuth = result.HTTPAuthUser != "" || result.HTTPAuthPassword != ""
if result.HTTPSHost, result.HTTPSPort, _, _, err = endpoint(p.HTTPSURL, "HTTPS proxy URL", "http", false); err != nil {
return settings{}, err
}
if result.SocksHost, result.SocksPort, _, _, err = endpoint(p.AllURL, "ALL/SOCKS proxy URL", "socks5", false); err != nil {
return settings{}, err
}
for _, bypass := range p.Bypass {
if strings.HasPrefix(bypass, ".") {
result.IgnoreHosts = append(result.IgnoreHosts, "*"+bypass)
continue
}
result.IgnoreHosts = append(result.IgnoreHosts, bypass)
}
return result, nil
}
func endpoint(raw, field, allowedScheme string, allowCredentials bool) (string, int, string, string, error) {
if raw == "" {
return "", 0, "", "", nil
}
u, err := url.Parse(raw)
if err != nil {
return "", 0, "", "", fmt.Errorf("%s is malformed", field)
}
if u.Scheme != allowedScheme {
if u.Scheme == "socks5h" {
return "", 0, "", "", fmt.Errorf("%s uses socks5h remote-DNS semantics that GNOME settings cannot preserve", field)
}
return "", 0, "", "", fmt.Errorf("%s scheme %q cannot be represented without changing semantics; GNOME requires %s", field, u.Scheme, allowedScheme)
}
username, password := "", ""
if u.User != nil {
if !allowCredentials {
return "", 0, "", "", fmt.Errorf("%s credentials cannot be represented safely by the GNOME Target", field)
}
username = u.User.Username()
password, _ = u.User.Password()
}
port, _ := strconv.Atoi(u.Port())
return u.Hostname(), port, username, password, nil
}
func fingerprint(value settings) string {
data, _ := json.Marshal(value)
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func quote(value string) string {
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))
for i, v := range values {
quoted[i] = quote(v)
}
return "[" + strings.Join(quoted, ", ") + "]"
}
func parseString(raw string) (string, error) {
raw = strings.TrimSpace(raw)
value, consumed, err := parseQuoted(raw)
if err != nil || consumed != len(raw) {
return "", fmt.Errorf("invalid gsettings string %q", raw)
}
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 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 {
value, consumed, err := parseQuoted(inner)
if err != nil {
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
}
result = append(result, value)
inner = strings.TrimSpace(inner[consumed:])
if inner == "" {
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)
}
}
}
func redactForProfile(err error, active *profile.Profile) error {
if err == nil {
return nil
}
message := err.Error()
if active != nil {
for _, raw := range []string{active.SingleURL, active.HTTPURL, active.HTTPSURL, active.AllURL} {
parsed, parseErr := url.Parse(raw)
if parseErr != nil || parsed.User == nil {
continue
}
if password, present := parsed.User.Password(); present && password != "" {
message = strings.ReplaceAll(message, password, "REDACTED")
}
}
}
return safeRedaction(profile.RedactText(message), err)
}
func redact(err error) error {
if err == nil {
return nil
}
return safeRedaction(profile.RedactText(err.Error()), err)
}
func safeRedaction(message string, original error) error {
var class error
for _, candidate := range []error{os.ErrPermission, fs.ErrNotExist, context.Canceled, context.DeadlineExceeded} {
if errors.Is(original, candidate) {
class = candidate
break
}
}
return &safeError{message: message, class: class}
}
type safeError struct {
message string
class error
}
func (e *safeError) Error() string { return e.message }
func (e *safeError) Is(target error) bool { return e.class != nil && errors.Is(e.class, target) }