// 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) }