fix: harden Shell Target recovery and storage
This commit is contained in:
+73
-113
@@ -3,7 +3,6 @@ package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -16,6 +15,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"proxyctl/internal/profile"
|
||||
"proxyctl/internal/securefs"
|
||||
"proxyctl/internal/target"
|
||||
)
|
||||
|
||||
@@ -37,6 +37,7 @@ type Configuration struct {
|
||||
OwnFiles bool
|
||||
UID int
|
||||
GID int
|
||||
TrustedRoots []string
|
||||
}
|
||||
|
||||
// ResolveConfiguration keeps user Shell effects with the invoking user under sudo.
|
||||
@@ -69,11 +70,16 @@ func ResolveConfiguration(environment profile.IdentityEnvironment) (Configuratio
|
||||
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
|
||||
}
|
||||
|
||||
@@ -110,110 +116,34 @@ type FileSystem interface {
|
||||
Remove(string) error
|
||||
}
|
||||
|
||||
type OSFileSystem struct{}
|
||||
type OSFileSystem struct{ TrustedRoots []string }
|
||||
|
||||
func (OSFileSystem) ReadFile(path string) ([]byte, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s is not a regular file", path)
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
func NewOSFileSystem(configuration Configuration) OSFileSystem {
|
||||
return OSFileSystem{TrustedRoots: append([]string(nil), configuration.TrustedRoots...)}
|
||||
}
|
||||
func (OSFileSystem) Remove(path string) error { return os.Remove(path) }
|
||||
func (OSFileSystem) FileMode(path string) (os.FileMode, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
func (f OSFileSystem) store(path string, owner *Ownership) securefs.Store {
|
||||
root := ""
|
||||
if len(f.TrustedRoots) == 0 {
|
||||
root = string(filepath.Separator)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return 0, fmt.Errorf("%s is not a regular file", path)
|
||||
for _, candidate := range f.TrustedRoots {
|
||||
if pathWithin(path, candidate) && (root == "" || len(filepath.Clean(candidate)) > len(filepath.Clean(root))) {
|
||||
root = candidate
|
||||
}
|
||||
}
|
||||
return info.Mode().Perm(), nil
|
||||
var secureOwner *securefs.Owner
|
||||
if owner != nil {
|
||||
secureOwner = &securefs.Owner{UID: owner.UID, GID: owner.GID}
|
||||
}
|
||||
return securefs.Store{TrustedRoot: root, Owner: secureOwner}
|
||||
}
|
||||
func (OSFileSystem) AtomicWrite(path string, data []byte, mode os.FileMode, owner *Ownership) error {
|
||||
if !filepath.IsAbs(path) {
|
||||
return errors.New("Shell Target path must be absolute")
|
||||
}
|
||||
directory := filepath.Dir(path)
|
||||
var missing []string
|
||||
for current := directory; ; current = filepath.Dir(current) {
|
||||
if _, err := os.Stat(current); err == nil {
|
||||
break
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
missing = append(missing, current)
|
||||
if filepath.Dir(current) == current {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := len(missing) - 1; i >= 0; i-- {
|
||||
if owner != nil && (owner.UID != os.Geteuid() || owner.GID != os.Getegid()) {
|
||||
if err := os.Chown(missing[i], owner.UID, owner.GID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.Chmod(missing[i], 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var temporary string
|
||||
for range 100 {
|
||||
bytes := make([]byte, 8)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary = filepath.Join(directory, ".proxyctl-"+hex.EncodeToString(bytes))
|
||||
file, err := os.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||
if errors.Is(err, fs.ErrExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
_ = os.Remove(temporary)
|
||||
}
|
||||
}()
|
||||
if owner != nil && (owner.UID != os.Geteuid() || owner.GID != os.Getegid()) {
|
||||
if err = file.Chown(owner.UID, owner.GID); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = file.Write(data); err == nil {
|
||||
err = file.Sync()
|
||||
}
|
||||
if closeErr := file.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(temporary, path); err != nil {
|
||||
return err
|
||||
}
|
||||
ok = true
|
||||
dir, err := os.Open(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = dir.Sync()
|
||||
closeErr := dir.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
return errors.New("allocate temporary Shell Target file")
|
||||
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 {
|
||||
@@ -230,7 +160,8 @@ type Adapter struct {
|
||||
|
||||
func New(configuration Configuration, files FileSystem, environment Environment) *Adapter {
|
||||
if files == nil {
|
||||
files = OSFileSystem{}
|
||||
configured := NewOSFileSystem(configuration)
|
||||
files = configured
|
||||
}
|
||||
if environment == nil {
|
||||
environment = MapEnvironment{}
|
||||
@@ -326,15 +257,18 @@ func (a *Adapter) enable(active profile.Profile) error {
|
||||
}
|
||||
generated := renderEnabled(normalized)
|
||||
metadata.Fingerprint = fingerprint(generated)
|
||||
if err := a.writeOwned(a.configuration.EnvironmentFile, generated); err != nil {
|
||||
return fmt.Errorf("write Shell environment: %w", err)
|
||||
}
|
||||
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("write Shell recovery metadata after environment changed: %w", err)
|
||||
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
|
||||
}
|
||||
@@ -413,30 +347,46 @@ func renderRestored(original map[string]string) []byte {
|
||||
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 {
|
||||
text := string(data)
|
||||
supported := make(map[string]bool, len(proxyVariables))
|
||||
for _, name := range proxyVariables {
|
||||
if strings.Contains(text, "export "+name+"=") || strings.Contains(text, name+"=") {
|
||||
return true
|
||||
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
|
||||
cause error
|
||||
class error
|
||||
}
|
||||
|
||||
func (e *redactedError) Error() string { return e.message }
|
||||
func (e *redactedError) Unwrap() error { return e.cause }
|
||||
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, cause: err}
|
||||
return &redactedError{message: message, class: class}
|
||||
}
|
||||
for _, raw := range []string{active.SingleURL, active.HTTPURL, active.HTTPSURL, active.AllURL} {
|
||||
if raw == "" {
|
||||
@@ -449,7 +399,16 @@ func redactError(err error, active *profile.Profile) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
return &redactedError{message: message, cause: err}
|
||||
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 {
|
||||
@@ -465,6 +424,7 @@ func (a *Adapter) Install(_ context.Context, executable string) error {
|
||||
cleaned += "\n"
|
||||
}
|
||||
block := integrationStart + "\n" +
|
||||
"[ -r " + shellQuote(a.configuration.EnvironmentFile) + " ] && . " + shellQuote(a.configuration.EnvironmentFile) + "\n" +
|
||||
"proxyctl() {\n" +
|
||||
" command " + shellQuote(executable) + " \"$@\"\n" +
|
||||
" proxyctl_status=$?\n" +
|
||||
|
||||
@@ -56,19 +56,73 @@ func TestShellAdapterRunsReusableTargetContract(t *testing.T) {
|
||||
return failed.SetDesired(context.Background(), true, activeProfile())
|
||||
},
|
||||
PartialFailure: func(t *testing.T) target.OperationResult {
|
||||
failed := newAdapterWithFS(t, shellName, nil, &failingFS{FileSystem: shell.OSFileSystem{}, failState: true})
|
||||
failed := newAdapterWithFS(t, shellName, nil, &failingFS{FileSystem: shell.OSFileSystem{}, failEnvironment: true})
|
||||
return failed.SetDesired(context.Background(), true, activeProfile())
|
||||
},
|
||||
RedactionFailure: func(t *testing.T) target.OperationResult {
|
||||
failed := newAdapterWithFS(t, shellName, nil, &failingFS{FileSystem: shell.OSFileSystem{}, failWrite: true})
|
||||
return failed.SetDesired(context.Background(), true, activeProfile())
|
||||
},
|
||||
RecoveryRetry: func(t *testing.T) (target.OperationResult, target.OperationResult, target.OperationResult) {
|
||||
files := &failingFS{FileSystem: shell.OSFileSystem{}, failRecoveryOnce: true}
|
||||
failed := newAdapterWithFS(t, shellName, map[string]string{"HTTP_PROXY": "http://prior.example:3128"}, files)
|
||||
first := failed.SetDesired(context.Background(), true, activeProfile())
|
||||
retry := failed.SetDesired(context.Background(), true, activeProfile())
|
||||
disabled := failed.SetDesired(context.Background(), false, nil)
|
||||
return first, retry, disabled
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryFailureRetryPreservesOriginalShellState(t *testing.T) {
|
||||
files := &failingFS{FileSystem: shell.OSFileSystem{}, failRecoveryOnce: true}
|
||||
a := newAdapterWithFS(t, "/bin/bash", map[string]string{"HTTP_PROXY": "http://prior.example:3128"}, files)
|
||||
first := a.SetDesired(context.Background(), true, activeProfile())
|
||||
if first.Err == nil || first.Inspection.State != target.External {
|
||||
t.Fatalf("first enable = %#v", first)
|
||||
}
|
||||
if _, err := os.Stat(a.EnvironmentPath()); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("managed environment was visible before recovery persisted: %v", err)
|
||||
}
|
||||
if retry := a.SetDesired(context.Background(), true, activeProfile()); retry.Err != nil || retry.Inspection.State != target.Enabled {
|
||||
t.Fatalf("retry = %#v", retry)
|
||||
}
|
||||
if disabled := a.SetDesired(context.Background(), false, nil); disabled.Err != nil || disabled.Inspection.State != target.External {
|
||||
t.Fatalf("disable = %#v", disabled)
|
||||
}
|
||||
if restored := readFile(t, a.EnvironmentPath()); !strings.Contains(restored, "http://prior.example:3128") || strings.Contains(restored, "proxy.example:8080") {
|
||||
t.Fatalf("original was not restored: %s", restored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizesOnlyExactSupportedShellProxySyntax(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, contents string
|
||||
state target.State
|
||||
}{
|
||||
{"comment", "# export HTTP_PROXY='http://comment.example:8080'\n", target.Disabled},
|
||||
{"prefixed variable", "export NOT_HTTP_PROXY='http://wrong.example:8080'\n", target.Disabled},
|
||||
{"prose", "echo export HTTP_PROXY=http://wrong.example:8080\n", target.Disabled},
|
||||
{"unset", "unset HTTP_PROXY http_proxy\n", target.Disabled},
|
||||
{"assignment", "HTTP_PROXY='http://external.example:8080'\n", target.External},
|
||||
{"export assignment", "export http_proxy='http://external.example:8080'\n", target.External},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
a := newAdapter(t, "/bin/bash", nil)
|
||||
if err := os.MkdirAll(filepath.Dir(a.EnvironmentPath()), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(a.EnvironmentPath(), []byte(tc.contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertState(t, a, activeProfile(), tc.state)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellTargetContract(t *testing.T) {
|
||||
contract := func(t *testing.T, shellName string) {
|
||||
t.Helper()
|
||||
@@ -146,14 +200,14 @@ func TestShellTargetContract(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("permission-and-partial-failure-reinspect", func(t *testing.T) {
|
||||
fs := &failingFS{FileSystem: shell.OSFileSystem{}, failState: true}
|
||||
fs := &failingFS{FileSystem: shell.OSFileSystem{}, failEnvironment: true}
|
||||
a := newAdapterWithFS(t, shellName, nil, fs)
|
||||
got := a.SetDesired(context.Background(), true, activeProfile())
|
||||
if got.Err == nil || !errors.Is(got.Err, os.ErrPermission) {
|
||||
t.Fatalf("permission error = %#v", got)
|
||||
}
|
||||
if got.Inspection.State != target.External {
|
||||
t.Fatalf("partial write state = %s, want External", got.Inspection.State)
|
||||
if got.Inspection.State != target.Drifted {
|
||||
t.Fatalf("partial write state = %s, want Drifted", got.Inspection.State)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -200,6 +254,80 @@ func TestIntegrationInstallUninstallPreservesStartupFileAndLoadsAfterCommand(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescriptorAnchoredWritesRejectSymlinkedAncestorVictims(t *testing.T) {
|
||||
t.Run("configuration", func(t *testing.T) {
|
||||
root, victim := t.TempDir(), t.TempDir()
|
||||
if err := os.Symlink(victim, filepath.Join(root, "config")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := shell.New(shell.Configuration{Shell: "/bin/bash", StartupFile: filepath.Join(root, "home", ".bashrc"), EnvironmentFile: filepath.Join(root, "config", "proxyctl", "shell.env"), RecoveryFile: filepath.Join(root, "state", "proxyctl", "shell.json"), TrustedRoots: []string{root}}, nil, shell.MapEnvironment{})
|
||||
if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err == nil {
|
||||
t.Fatal("enable followed symlinked configuration ancestor")
|
||||
}
|
||||
assertVictimEmpty(t, victim)
|
||||
})
|
||||
t.Run("configuration final file", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
victim := filepath.Join(t.TempDir(), "victim")
|
||||
if err := os.WriteFile(victim, []byte("unchanged\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "config", "proxyctl"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
environmentFile := filepath.Join(root, "config", "proxyctl", "shell.env")
|
||||
if err := os.Symlink(victim, environmentFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := shell.New(shell.Configuration{Shell: "/bin/bash", StartupFile: filepath.Join(root, ".bashrc"), EnvironmentFile: environmentFile, RecoveryFile: filepath.Join(root, "state", "proxyctl", "shell.json"), TrustedRoots: []string{root}}, nil, shell.MapEnvironment{})
|
||||
if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err == nil {
|
||||
t.Fatal("enable replaced symlinked final configuration file")
|
||||
}
|
||||
if got := readFile(t, victim); got != "unchanged\n" {
|
||||
t.Fatalf("final symlink victim changed to %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("state", func(t *testing.T) {
|
||||
root, victim := t.TempDir(), t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, ".local"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(victim, filepath.Join(root, ".local", "state")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := shell.New(shell.Configuration{Shell: "/bin/bash", StartupFile: filepath.Join(root, ".bashrc"), EnvironmentFile: filepath.Join(root, ".config", "proxyctl", "shell.env"), RecoveryFile: filepath.Join(root, ".local", "state", "proxyctl", "shell.json"), TrustedRoots: []string{root}}, nil, shell.MapEnvironment{})
|
||||
if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err == nil {
|
||||
t.Fatal("enable followed symlinked state ancestor")
|
||||
}
|
||||
assertVictimEmpty(t, victim)
|
||||
if _, err := os.Stat(a.EnvironmentPath()); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("environment changed before secure recovery: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("integration", func(t *testing.T) {
|
||||
root, victim := t.TempDir(), t.TempDir()
|
||||
if err := os.Symlink(victim, filepath.Join(root, "home")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := shell.New(shell.Configuration{Shell: "/bin/bash", StartupFile: filepath.Join(root, "home", ".bashrc"), EnvironmentFile: filepath.Join(root, "config", "shell.env"), RecoveryFile: filepath.Join(root, "state", "shell.json"), TrustedRoots: []string{root}}, nil, shell.MapEnvironment{})
|
||||
if err := a.Install(context.Background(), "/bin/proxyctl"); err == nil {
|
||||
t.Fatal("install followed symlinked startup ancestor")
|
||||
}
|
||||
assertVictimEmpty(t, victim)
|
||||
})
|
||||
}
|
||||
|
||||
func assertVictimEmpty(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("symlink victim changed: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationRefusesStartupSymlinkWithoutChangingVictim(t *testing.T) {
|
||||
a := newAdapter(t, "/bin/bash", nil)
|
||||
if err := os.MkdirAll(filepath.Dir(a.StartupPath()), 0o700); err != nil {
|
||||
@@ -237,6 +365,38 @@ func TestMalformedIntegrationMarkersNeverCauseStartupContentDeletion(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreshIntegratedShellSourcesExistingEnvironmentAtStartup(t *testing.T) {
|
||||
for _, shellName := range []string{"bash", "zsh"} {
|
||||
t.Run(shellName, func(t *testing.T) {
|
||||
path, err := exec.LookPath(shellName)
|
||||
if err != nil {
|
||||
t.Skip(shellName + " is not installed")
|
||||
}
|
||||
a := newAdapter(t, filepath.Join("/bin", shellName), nil)
|
||||
if enabled := a.SetDesired(context.Background(), true, activeProfile()); enabled.Err != nil {
|
||||
t.Fatal(enabled.Err)
|
||||
}
|
||||
if err := a.Install(context.Background(), "/bin/true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var command *exec.Cmd
|
||||
if shellName == "bash" {
|
||||
command = exec.Command(path, "--noprofile", "--rcfile", a.StartupPath(), "-i", "-c", `printf %s "$HTTP_PROXY"`)
|
||||
} else {
|
||||
command = exec.Command(path, "-d", "-i", "-c", `printf %s "$HTTP_PROXY"`)
|
||||
command.Env = append(os.Environ(), "ZDOTDIR="+filepath.Dir(a.StartupPath()))
|
||||
}
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("start fresh %s: %v", shellName, err)
|
||||
}
|
||||
if got := string(output); got != "http://alice:secret@proxy.example:8080" {
|
||||
t.Fatalf("fresh %s HTTP_PROXY = %q", shellName, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledBashWrapperReloadsParentEnvironmentAfterProxyctlExits(t *testing.T) {
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
t.Skip("bash is not installed")
|
||||
@@ -322,7 +482,11 @@ func newAdapter(t *testing.T, shellName string, env map[string]string) *shell.Ad
|
||||
func newAdapterWithFS(t *testing.T, shellName string, env map[string]string, fs shell.FileSystem) *shell.Adapter {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
return shell.New(shell.Configuration{Shell: shellName, StartupFile: filepath.Join(root, "home", ".bashrc"), EnvironmentFile: filepath.Join(root, "config", "shell.env"), RecoveryFile: filepath.Join(root, "state", "shell.json")}, fs, shell.MapEnvironment(env))
|
||||
startup := ".bashrc"
|
||||
if filepath.Base(shellName) == "zsh" {
|
||||
startup = ".zshrc"
|
||||
}
|
||||
return shell.New(shell.Configuration{Shell: shellName, StartupFile: filepath.Join(root, "home", startup), EnvironmentFile: filepath.Join(root, "config", "shell.env"), RecoveryFile: filepath.Join(root, "state", "shell.json")}, fs, shell.MapEnvironment(env))
|
||||
}
|
||||
func assertState(t *testing.T, a *shell.Adapter, p *profile.Profile, want target.State) {
|
||||
t.Helper()
|
||||
@@ -341,7 +505,7 @@ func readFile(t *testing.T, path string) string {
|
||||
|
||||
type failingFS struct {
|
||||
shell.FileSystem
|
||||
failWrite, failState, failRestore, permission bool
|
||||
failWrite, failState, failEnvironment, failRestore, permission, failRecoveryOnce bool
|
||||
}
|
||||
|
||||
func (f *failingFS) AtomicWrite(path string, data []byte, mode os.FileMode, owner *shell.Ownership) error {
|
||||
@@ -351,9 +515,16 @@ func (f *failingFS) AtomicWrite(path string, data []byte, mode os.FileMode, owne
|
||||
if f.failWrite {
|
||||
return errors.New("write http://alice:secret@proxy.example:8080 failed")
|
||||
}
|
||||
if f.failEnvironment && strings.HasSuffix(path, "shell.env") {
|
||||
return os.ErrPermission
|
||||
}
|
||||
if f.failRestore && strings.HasSuffix(path, "shell.env") {
|
||||
return errors.New("restore http://alice:restore-secret@prior.example:8080 failed")
|
||||
}
|
||||
if f.failRecoveryOnce && strings.HasSuffix(path, "shell.json") {
|
||||
f.failRecoveryOnce = false
|
||||
return os.ErrPermission
|
||||
}
|
||||
if f.failState && strings.HasSuffix(path, "shell.json") {
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user