577 lines
20 KiB
Go
577 lines
20 KiB
Go
// Package shell implements the Bash and Zsh Shell Target.
|
|
package shell
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"golang.org/x/sys/unix"
|
|
|
|
"proxyctl/internal/profile"
|
|
"proxyctl/internal/securefs"
|
|
"proxyctl/internal/target"
|
|
)
|
|
|
|
const (
|
|
integrationStart = "# >>> proxyctl shell integration >>>"
|
|
integrationEnd = "# <<< proxyctl shell integration <<<"
|
|
shellRecoveryVersion = 1
|
|
)
|
|
|
|
var proxyVariables = []string{
|
|
"HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy",
|
|
"ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy",
|
|
}
|
|
|
|
type Configuration struct {
|
|
Shell string
|
|
StartupFile string
|
|
EnvironmentFile string
|
|
RecoveryFile string
|
|
OwnFiles bool
|
|
UID int
|
|
GID int
|
|
TrustedRoots []string
|
|
}
|
|
|
|
// ResolveConfiguration keeps user Shell effects with the invoking user under sudo.
|
|
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)
|
|
}
|
|
invokingUser := current.Username != owner.Username
|
|
configDirectory := filepath.Dir(profileConfiguration.Path)
|
|
stateDirectory := ""
|
|
if value, ok := environment.LookupEnv("XDG_STATE_HOME"); ok && filepath.IsAbs(value) {
|
|
if !invokingUser || pathWithin(value, owner.HomeDir) {
|
|
stateDirectory = value
|
|
}
|
|
}
|
|
if stateDirectory == "" {
|
|
stateDirectory = filepath.Join(owner.HomeDir, ".local", "state")
|
|
}
|
|
shellPath, _ := environment.LookupEnv("SHELL")
|
|
startupFile := ""
|
|
switch filepath.Base(shellPath) {
|
|
case "bash":
|
|
startupFile = filepath.Join(owner.HomeDir, ".bashrc")
|
|
case "zsh":
|
|
startupFile = filepath.Join(owner.HomeDir, ".zshrc")
|
|
}
|
|
stateRoot := owner.HomeDir
|
|
if !pathWithin(stateDirectory, owner.HomeDir) {
|
|
stateRoot = stateDirectory
|
|
}
|
|
return Configuration{
|
|
Shell: shellPath, StartupFile: startupFile,
|
|
EnvironmentFile: filepath.Join(configDirectory, "shell.env"),
|
|
RecoveryFile: filepath.Join(stateDirectory, "proxyctl", "shell.json"),
|
|
OwnFiles: true, UID: owner.UID, GID: owner.GID,
|
|
TrustedRoots: []string{owner.HomeDir, profileConfiguration.TrustedRoot, stateRoot},
|
|
}, 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 Environment interface{ LookupEnv(string) (string, bool) }
|
|
type MapEnvironment map[string]string
|
|
|
|
func (m MapEnvironment) LookupEnv(name string) (string, bool) { value, ok := m[name]; return value, ok }
|
|
|
|
type Ownership struct{ UID, GID int }
|
|
|
|
type FileSystem interface {
|
|
ReadFile(string) ([]byte, error)
|
|
FileMode(string) (os.FileMode, error)
|
|
AtomicWrite(string, []byte, os.FileMode, *Ownership) error
|
|
Remove(string) error
|
|
}
|
|
|
|
type OSFileSystem struct{ TrustedRoots []string }
|
|
|
|
func NewOSFileSystem(configuration Configuration) OSFileSystem {
|
|
return OSFileSystem{TrustedRoots: append([]string(nil), configuration.TrustedRoots...)}
|
|
}
|
|
func (f OSFileSystem) store(path string, owner *Ownership) securefs.Store {
|
|
root := ""
|
|
if len(f.TrustedRoots) == 0 {
|
|
root = string(filepath.Separator)
|
|
}
|
|
for _, candidate := range f.TrustedRoots {
|
|
if pathWithin(path, candidate) && (root == "" || len(filepath.Clean(candidate)) > len(filepath.Clean(root))) {
|
|
root = candidate
|
|
}
|
|
}
|
|
var secureOwner *securefs.Owner
|
|
if owner != nil {
|
|
secureOwner = &securefs.Owner{UID: owner.UID, GID: owner.GID}
|
|
}
|
|
return securefs.Store{TrustedRoot: root, Owner: secureOwner}
|
|
}
|
|
func (f OSFileSystem) ReadFile(path string) ([]byte, error) { return f.store(path, nil).ReadFile(path) }
|
|
func (f OSFileSystem) Remove(path string) error { return f.store(path, nil).Remove(path) }
|
|
func (f OSFileSystem) FileMode(path string) (os.FileMode, error) {
|
|
return f.store(path, nil).FileMode(path)
|
|
}
|
|
func (f OSFileSystem) AtomicWrite(path string, data []byte, mode os.FileMode, owner *Ownership) error {
|
|
return f.store(path, owner).AtomicWrite(path, data, mode)
|
|
}
|
|
|
|
type recovery struct {
|
|
Version int `json:"version"`
|
|
Original map[string]string `json:"original"`
|
|
OriginalScript *string `json:"originalScript,omitempty"`
|
|
Fingerprint string `json:"fingerprint"`
|
|
}
|
|
|
|
type Adapter struct {
|
|
configuration Configuration
|
|
files FileSystem
|
|
environment Environment
|
|
}
|
|
|
|
func New(configuration Configuration, files FileSystem, environment Environment) *Adapter {
|
|
if files == nil {
|
|
configured := NewOSFileSystem(configuration)
|
|
files = configured
|
|
}
|
|
if environment == nil {
|
|
environment = MapEnvironment{}
|
|
}
|
|
return &Adapter{configuration: configuration, files: files, environment: environment}
|
|
}
|
|
|
|
func (a *Adapter) ID() string { return "shell" }
|
|
func (a *Adapter) Description() target.Description {
|
|
return target.Description{Name: "Shell", Summary: "Exports proxy variables for commands started by integrated Bash and Zsh sessions.", Details: "Affects commands started by the integrated Shell; it does not configure system services, desktop settings, or already-running processes. proxyctl owns a generated environment fragment while its one-time startup hook remains in the user's Shell file. User-file write permission is required; after proxyctl exits, the installed wrapper reloads exports and unsets into the parent Shell."}
|
|
}
|
|
func (a *Adapter) Availability(context.Context) target.Availability {
|
|
switch filepath.Base(a.configuration.Shell) {
|
|
case "bash":
|
|
return target.Availability{Available: true, Evidence: "Bash detected"}
|
|
case "zsh":
|
|
return target.Availability{Available: true, Evidence: "Zsh detected"}
|
|
default:
|
|
return target.Availability{Evidence: "Shell Target requires Bash or Zsh; neither was detected"}
|
|
}
|
|
}
|
|
func (a *Adapter) Confirmation(bool) string { return "" }
|
|
func (a *Adapter) EnvironmentPath() string { return a.configuration.EnvironmentFile }
|
|
func (a *Adapter) RecoveryPath() string { return a.configuration.RecoveryFile }
|
|
func (a *Adapter) StartupPath() string { return a.configuration.StartupFile }
|
|
|
|
// DiagnosticChecks performs no writes or command execution.
|
|
func (a *Adapter) DiagnosticChecks(_ context.Context, _ *profile.Profile) []target.Check {
|
|
paths := []string{a.configuration.EnvironmentFile, a.configuration.RecoveryFile}
|
|
writable := true
|
|
var evidence []string
|
|
for _, path := range paths {
|
|
ok, detail := writablePath(path)
|
|
writable = writable && ok
|
|
evidence = append(evidence, detail)
|
|
}
|
|
return []target.Check{
|
|
{Name: "required commands", OK: true, Evidence: "Shell Target requires no external commands"},
|
|
{Name: "write authority", OK: writable, Evidence: strings.Join(evidence, "; ")},
|
|
}
|
|
}
|
|
|
|
func writablePath(path string) (bool, string) {
|
|
candidate := path
|
|
for {
|
|
info, err := os.Stat(candidate)
|
|
if err == nil {
|
|
if !info.IsDir() {
|
|
candidate = filepath.Dir(candidate)
|
|
}
|
|
if err := unix.Access(candidate, unix.W_OK); err != nil {
|
|
return false, candidate + " is not writable: " + err.Error()
|
|
}
|
|
return true, candidate + " is writable"
|
|
}
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
return false, candidate + " cannot be inspected: " + err.Error()
|
|
}
|
|
parent := filepath.Dir(candidate)
|
|
if parent == candidate {
|
|
return false, path + " has no writable existing ancestor"
|
|
}
|
|
candidate = parent
|
|
}
|
|
}
|
|
|
|
func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection {
|
|
metadata, managed, metadataErr := a.readRecovery()
|
|
generated, generatedErr := a.files.ReadFile(a.configuration.EnvironmentFile)
|
|
if managed || (metadataErr != nil && !errors.Is(metadataErr, fs.ErrNotExist)) {
|
|
if metadataErr != nil {
|
|
return target.Inspection{State: target.Drifted, Evidence: "managed Shell recovery metadata cannot be read"}
|
|
}
|
|
if generatedErr != nil {
|
|
return target.Inspection{State: target.Drifted, Evidence: "managed Shell environment is missing or unreadable"}
|
|
}
|
|
if active != nil && fingerprint(generated) == metadata.Fingerprint && fingerprint(renderEnabled(*active)) == metadata.Fingerprint {
|
|
return target.Inspection{State: target.Enabled, Evidence: "managed Shell environment matches the Active Profile"}
|
|
}
|
|
return target.Inspection{State: target.Drifted, Evidence: "managed Shell environment does not match the Active Profile"}
|
|
}
|
|
if generatedErr == nil && recognizesProxy(generated) {
|
|
return target.Inspection{State: target.External, Evidence: "recognized proxy assignments exist in the Shell environment file"}
|
|
}
|
|
for _, name := range proxyVariables {
|
|
if value, ok := a.environment.LookupEnv(name); ok && value != "" {
|
|
return target.Inspection{State: target.External, Evidence: "recognized proxy variable " + name + " exists"}
|
|
}
|
|
}
|
|
return target.Inspection{State: target.Disabled, Evidence: "no recognized Shell proxy configuration detected"}
|
|
}
|
|
|
|
func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.Profile) target.OperationResult {
|
|
// Enabling requires a supported current Shell, but disabling must remain
|
|
// available so reset can remove managed state after the user's Shell changes.
|
|
if enabled && !a.Availability(ctx).Available {
|
|
return target.OperationResult{Inspection: a.Inspect(ctx, active), Err: errors.New("Shell Target is unavailable: Bash or Zsh was not detected")}
|
|
}
|
|
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
|
|
if enabled {
|
|
err = a.enable(*active)
|
|
} else {
|
|
err = a.disable()
|
|
}
|
|
inspection := a.Inspect(ctx, active)
|
|
if err != nil {
|
|
err = redactError(err, active)
|
|
}
|
|
return target.OperationResult{Inspection: inspection, Err: err}
|
|
}
|
|
|
|
func (a *Adapter) enable(active profile.Profile) error {
|
|
normalized, err := profile.ValidateAndNormalize(active)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
metadata, managed, err := a.readRecovery()
|
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return fmt.Errorf("read Shell recovery metadata: %w", err)
|
|
}
|
|
if !managed {
|
|
metadata.Version = shellRecoveryVersion
|
|
if existing, readErr := a.files.ReadFile(a.configuration.EnvironmentFile); readErr == nil && recognizesProxy(existing) {
|
|
original := string(existing)
|
|
metadata.OriginalScript = &original
|
|
} else {
|
|
metadata.Original = make(map[string]string)
|
|
for _, name := range proxyVariables {
|
|
if value, ok := a.environment.LookupEnv(name); ok {
|
|
metadata.Original[name] = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
generated := renderEnabled(normalized)
|
|
metadata.Fingerprint = fingerprint(generated)
|
|
data, err := json.MarshalIndent(metadata, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// The true original must be durable before the managed environment becomes
|
|
// visible. A retry then reuses this metadata instead of snapshotting its own
|
|
// partially applied output as External state.
|
|
if err := a.writeOwned(a.configuration.RecoveryFile, append(data, '\n')); err != nil {
|
|
return fmt.Errorf("persist Shell recovery metadata before changing environment: %w", err)
|
|
}
|
|
if err := a.writeOwned(a.configuration.EnvironmentFile, generated); err != nil {
|
|
return fmt.Errorf("write Shell environment after recovery metadata persisted: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *Adapter) disable() error {
|
|
metadata, managed, err := a.readRecovery()
|
|
if errors.Is(err, fs.ErrNotExist) || !managed {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("read Shell recovery metadata: %w", err)
|
|
}
|
|
restored := renderRestored(metadata.Original)
|
|
if metadata.OriginalScript != nil {
|
|
restored = []byte(*metadata.OriginalScript)
|
|
}
|
|
if err := a.writeOwned(a.configuration.EnvironmentFile, restored); err != nil {
|
|
return fmt.Errorf("restore Shell environment: %w", err)
|
|
}
|
|
if err := a.files.Remove(a.configuration.RecoveryFile); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return fmt.Errorf("remove Shell recovery metadata after environment changed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *Adapter) readRecovery() (recovery, bool, error) {
|
|
data, err := a.files.ReadFile(a.configuration.RecoveryFile)
|
|
if err != nil {
|
|
return recovery{}, false, err
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &fields); err != nil {
|
|
return recovery{}, true, err
|
|
}
|
|
if fields == nil {
|
|
return recovery{}, true, errors.New("Shell recovery metadata must be a JSON object")
|
|
}
|
|
var value recovery
|
|
if err := json.Unmarshal(data, &value); err != nil {
|
|
return recovery{}, true, err
|
|
}
|
|
if err := validateRecovery(value, fields); err != nil {
|
|
return recovery{}, true, err
|
|
}
|
|
return value, true, nil
|
|
}
|
|
|
|
func validateRecovery(value recovery, fields map[string]json.RawMessage) error {
|
|
for _, name := range []string{"version", "original", "fingerprint"} {
|
|
if _, present := fields[name]; !present {
|
|
return fmt.Errorf("Shell recovery metadata is missing required field %q", name)
|
|
}
|
|
}
|
|
if value.Version != shellRecoveryVersion {
|
|
return fmt.Errorf("unsupported Shell recovery metadata version %d", value.Version)
|
|
}
|
|
decoded, err := hex.DecodeString(value.Fingerprint)
|
|
if err != nil || len(decoded) != sha256.Size {
|
|
return errors.New("Shell recovery metadata has an invalid fingerprint")
|
|
}
|
|
_, hasOriginalScript := fields["originalScript"]
|
|
if hasOriginalScript {
|
|
if value.OriginalScript == nil || value.Original != nil || len(*value.OriginalScript) == 0 || !recognizesProxy([]byte(*value.OriginalScript)) {
|
|
return errors.New("Shell recovery metadata has inconsistent original script state")
|
|
}
|
|
return nil
|
|
}
|
|
if value.Original == nil {
|
|
return errors.New("Shell recovery metadata is missing the original environment snapshot")
|
|
}
|
|
for name := range value.Original {
|
|
known := false
|
|
for _, candidate := range proxyVariables {
|
|
known = known || name == candidate
|
|
}
|
|
if !known {
|
|
return fmt.Errorf("Shell recovery metadata contains unsupported variable %q", name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func (a *Adapter) ownership() *Ownership {
|
|
if !a.configuration.OwnFiles {
|
|
return nil
|
|
}
|
|
return &Ownership{UID: a.configuration.UID, GID: a.configuration.GID}
|
|
}
|
|
func (a *Adapter) writeOwned(path string, data []byte) error {
|
|
return a.files.AtomicWrite(path, data, 0o600, a.ownership())
|
|
}
|
|
|
|
func renderEnabled(p profile.Profile) []byte {
|
|
values := map[string]string{"HTTP_PROXY": p.HTTPURL, "http_proxy": p.HTTPURL, "HTTPS_PROXY": p.HTTPSURL, "https_proxy": p.HTTPSURL, "ALL_PROXY": p.AllURL, "all_proxy": p.AllURL, "NO_PROXY": strings.Join(p.Bypass, ","), "no_proxy": strings.Join(p.Bypass, ",")}
|
|
var output strings.Builder
|
|
output.WriteString("# Generated by proxyctl. Do not edit.\n")
|
|
for _, name := range proxyVariables {
|
|
if value := values[name]; value != "" {
|
|
fmt.Fprintf(&output, "export %s=%s\n", name, shellQuote(value))
|
|
} else {
|
|
fmt.Fprintf(&output, "unset %s\n", name)
|
|
}
|
|
}
|
|
return []byte(output.String())
|
|
}
|
|
func renderRestored(original map[string]string) []byte {
|
|
var output strings.Builder
|
|
output.WriteString("# Generated by proxyctl. Restores the environment present before enable.\n")
|
|
var unset []string
|
|
for _, name := range proxyVariables {
|
|
if value, ok := original[name]; ok {
|
|
fmt.Fprintf(&output, "export %s=%s\n", name, shellQuote(value))
|
|
} else {
|
|
unset = append(unset, name)
|
|
}
|
|
}
|
|
if len(unset) > 0 {
|
|
fmt.Fprintf(&output, "unset %s\n", strings.Join(unset, " "))
|
|
}
|
|
return []byte(output.String())
|
|
}
|
|
func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" }
|
|
func fingerprint(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
|
func recognizesProxy(data []byte) bool {
|
|
supported := make(map[string]bool, len(proxyVariables))
|
|
for _, name := range proxyVariables {
|
|
supported[name] = true
|
|
}
|
|
for _, raw := range strings.Split(string(data), "\n") {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "export") {
|
|
if len(line) == len("export") || (line[len("export")] != ' ' && line[len("export")] != '\t') {
|
|
continue
|
|
}
|
|
line = strings.TrimSpace(line[len("export"):])
|
|
}
|
|
name, _, found := strings.Cut(line, "=")
|
|
if !found || strings.TrimSpace(name) != name || !supported[name] {
|
|
continue
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
type redactedError struct {
|
|
message string
|
|
class error
|
|
}
|
|
|
|
func (e *redactedError) Error() string { return e.message }
|
|
func (e *redactedError) Is(target error) bool { return e.class != nil && errors.Is(e.class, target) }
|
|
|
|
func redactError(err error, active *profile.Profile) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
message := profile.RedactText(err.Error())
|
|
class := classifySafeError(err)
|
|
if active == nil {
|
|
return &redactedError{message: message, class: class}
|
|
}
|
|
for _, raw := range []string{active.SingleURL, active.HTTPURL, active.HTTPSURL, active.AllURL} {
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
message = strings.ReplaceAll(message, raw, profile.ObscureURL(raw))
|
|
if parsed, parseErr := url.Parse(raw); parseErr == nil && parsed.User != nil {
|
|
if password, ok := parsed.User.Password(); ok && password != "" {
|
|
message = strings.ReplaceAll(message, password, "REDACTED")
|
|
}
|
|
}
|
|
}
|
|
return &redactedError{message: message, class: class}
|
|
}
|
|
|
|
func classifySafeError(err error) error {
|
|
for _, safe := range []error{os.ErrPermission, fs.ErrNotExist, fs.ErrExist, context.Canceled, context.DeadlineExceeded} {
|
|
if errors.Is(err, safe) {
|
|
return safe
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *Adapter) Install(_ context.Context, executable string) error {
|
|
contents, err := a.files.ReadFile(a.configuration.StartupFile)
|
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return fmt.Errorf("read Shell startup file: %w", err)
|
|
}
|
|
cleaned := removeIntegration(string(contents))
|
|
if strings.Contains(cleaned, integrationStart) || strings.Contains(cleaned, integrationEnd) {
|
|
return errors.New("Shell startup file contains malformed proxyctl integration markers; no changes made")
|
|
}
|
|
if cleaned != "" && !strings.HasSuffix(cleaned, "\n") {
|
|
cleaned += "\n"
|
|
}
|
|
block := integrationStart + "\n" +
|
|
"[ -r " + shellQuote(a.configuration.EnvironmentFile) + " ] && . " + shellQuote(a.configuration.EnvironmentFile) + "\n" +
|
|
"proxyctl() {\n" +
|
|
" command " + shellQuote(executable) + " \"$@\"\n" +
|
|
" proxyctl_status=$?\n" +
|
|
" [ -r " + shellQuote(a.configuration.EnvironmentFile) + " ] && . " + shellQuote(a.configuration.EnvironmentFile) + "\n" +
|
|
" return $proxyctl_status\n" +
|
|
"}\n" + integrationEnd + "\n"
|
|
mode := os.FileMode(0o644)
|
|
if existingMode, modeErr := a.files.FileMode(a.configuration.StartupFile); modeErr == nil {
|
|
mode = existingMode
|
|
}
|
|
if err := a.files.AtomicWrite(a.configuration.StartupFile, []byte(cleaned+block), mode, a.ownership()); err != nil {
|
|
return fmt.Errorf("install Shell integration: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
func (a *Adapter) Uninstall(_ context.Context) error {
|
|
contents, err := a.files.ReadFile(a.configuration.StartupFile)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("read Shell startup file: %w", err)
|
|
}
|
|
cleaned := removeIntegration(string(contents))
|
|
if strings.Contains(cleaned, integrationStart) || strings.Contains(cleaned, integrationEnd) {
|
|
return errors.New("Shell startup file contains malformed proxyctl integration markers; no changes made")
|
|
}
|
|
if cleaned == string(contents) {
|
|
return nil
|
|
}
|
|
mode := os.FileMode(0o644)
|
|
if existingMode, modeErr := a.files.FileMode(a.configuration.StartupFile); modeErr == nil {
|
|
mode = existingMode
|
|
}
|
|
if err := a.files.AtomicWrite(a.configuration.StartupFile, []byte(cleaned), mode, a.ownership()); err != nil {
|
|
return fmt.Errorf("uninstall Shell integration: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
func removeIntegration(contents string) string {
|
|
for {
|
|
start := strings.Index(contents, integrationStart)
|
|
if start < 0 {
|
|
return contents
|
|
}
|
|
endRelative := strings.Index(contents[start:], integrationEnd)
|
|
if endRelative < 0 {
|
|
return contents
|
|
}
|
|
end := start + endRelative + len(integrationEnd)
|
|
if end < len(contents) && contents[end] == '\n' {
|
|
end++
|
|
}
|
|
contents = contents[:start] + contents[end:]
|
|
}
|
|
}
|