352 lines
13 KiB
Go
352 lines
13 KiB
Go
package netssh_test
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type installerFixture struct {
|
|
home, bin, assets string
|
|
env []string
|
|
}
|
|
|
|
func newInstallerFixture(t *testing.T) installerFixture {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
f := installerFixture{home: filepath.Join(root, "home"), bin: filepath.Join(root, "tools"), assets: filepath.Join(root, "assets")}
|
|
for _, dir := range []string{f.home, f.bin, f.assets} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
writeFixture(t, filepath.Join(f.bin, "curl"), `#!/bin/bash
|
|
set -eu
|
|
out=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in -o) out=$2; shift 2 ;; *) url=$1; shift ;; esac
|
|
done
|
|
case "$url" in
|
|
*/releases/latest) printf '{"tag_name": "v0.0.99"}\n'; exit ;;
|
|
*/checksums.txt) [[ "${CHECKSUM_MODE:-}" != missing ]] || exit 22; file="$FIXTURE_ASSETS/checksums.txt" ;;
|
|
*) file="$FIXTURE_ASSETS/netssh_linux_amd64" ;;
|
|
esac
|
|
if [[ -n "$out" ]]; then cp "$file" "$out"; else cat "$file"; fi
|
|
`, 0o755)
|
|
writeFixture(t, filepath.Join(f.bin, "uname"), "#!/bin/sh\ncase \"$1\" in -s) echo Linux;; -m) echo x86_64;; esac\n", 0o755)
|
|
// Any accidental runtime dependency on Python must fail the installation.
|
|
writeFixture(t, filepath.Join(f.bin, "python3"), "#!/bin/sh\nexit 99\n", 0o755)
|
|
binary := `#!/bin/bash
|
|
set -eu
|
|
case "$1" in
|
|
alias)
|
|
if [[ "$3" == fish ]]; then printf "alias j 'netssh'\n"; else printf "alias j='netssh'\n"; fi ;;
|
|
completion) exit 0 ;;
|
|
shell-init) exit 0 ;;
|
|
*) exit 1 ;;
|
|
esac
|
|
`
|
|
writeFixture(t, filepath.Join(f.assets, "netssh_linux_amd64"), binary, 0o755)
|
|
checksum := fmt.Sprintf("%x netssh_linux_amd64\n", sha256.Sum256([]byte(binary)))
|
|
writeFixture(t, filepath.Join(f.assets, "checksums.txt"), checksum, 0o644)
|
|
f.env = append(os.Environ(), "HOME="+f.home, "XDG_CONFIG_HOME="+filepath.Join(f.home, "config"), "XDG_CACHE_HOME="+filepath.Join(f.home, "cache"), "ZDOTDIR="+f.home, "SHELL=/bin/bash", "PATH="+f.bin+":"+os.Getenv("PATH"), "FIXTURE_ASSETS="+f.assets, "INSTALL_DIR="+filepath.Join(f.home, "local bin's"), "BASH_ENV=", "CHECKSUM_MODE=")
|
|
return f
|
|
}
|
|
|
|
func writeFixture(t *testing.T, path, content string, mode os.FileMode) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func (f installerFixture) run(t *testing.T) (string, error) {
|
|
t.Helper()
|
|
// Piping stdin mirrors curl | bash, including EOF for any child commands.
|
|
script, err := os.ReadFile("install.sh")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd := exec.Command("bash")
|
|
cmd.Env = f.env
|
|
cmd.Stdin = strings.NewReader(string(script))
|
|
out, err := cmd.CombinedOutput()
|
|
return string(out), err
|
|
}
|
|
|
|
func TestInstallerUpdatesAndConsolidatesProfilesWithoutPython(t *testing.T) {
|
|
f := newInstallerFixture(t)
|
|
original := "export KEEP=yes\n# netssh shell-init is documented here\necho 'netssh shell-init'\nnetssh shell-init\nnetssh shell-init # duplicate\nsource ~/.cache/netssh/aliases.sh 2>/dev/null\neval \"$(netssh alias)\"\nalias old='netssh old.example.com'\n"
|
|
target := filepath.Join(f.home, "dotfiles", "bashrc")
|
|
writeFixture(t, target, original, 0o600)
|
|
bashrc := filepath.Join(f.home, ".bashrc")
|
|
if err := os.Symlink(target, bashrc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFixture(t, filepath.Join(f.home, ".zshrc"), "export ZSH_KEEP=yes\nnetssh shell-init\n", 0o644)
|
|
writeFixture(t, filepath.Join(f.home, ".profile"), "export LOGIN_KEEP=yes\nnetssh shell-init\n", 0o644)
|
|
fish := filepath.Join(f.home, "config", "fish", "config.fish")
|
|
writeFixture(t, fish, "set KEEP yes\nnetssh alias --shell fish | source\n", 0o644)
|
|
installed := filepath.Join(f.home, "local bin's", "netssh")
|
|
writeFixture(t, installed, "old binary", 0o755)
|
|
if out, err := f.run(t); err != nil {
|
|
t.Fatalf("install: %v\n%s", err, out)
|
|
}
|
|
for _, path := range []string{bashrc, filepath.Join(f.home, ".zshrc"), fish} {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Count(string(data), "# >>> netssh >>>") != 1 {
|
|
t.Fatalf("bad managed blocks: %s", data)
|
|
}
|
|
}
|
|
data, _ := os.ReadFile(bashrc)
|
|
for _, kept := range []string{"export KEEP=yes", "# netssh shell-init is documented here", "echo 'netssh shell-init'"} {
|
|
if !strings.Contains(string(data), kept) {
|
|
t.Fatalf("unrelated line lost: %s", kept)
|
|
}
|
|
}
|
|
if strings.Contains(string(data), "alias old=") || strings.Contains(string(data), "eval ") {
|
|
t.Fatalf("legacy entries remain: %s", data)
|
|
}
|
|
if info, err := os.Lstat(bashrc); err != nil || info.Mode()&os.ModeSymlink == 0 {
|
|
t.Fatalf("symlink lost: %v", err)
|
|
}
|
|
if info, _ := os.Stat(target); info.Mode().Perm() != 0o600 {
|
|
t.Fatal("permissions changed")
|
|
}
|
|
backups, _ := filepath.Glob(target + ".netssh-backup.*")
|
|
if len(backups) != 1 {
|
|
t.Fatalf("backups: %v", backups)
|
|
}
|
|
backup, _ := os.ReadFile(backups[0])
|
|
if string(backup) != original {
|
|
t.Fatal("backup lost original content")
|
|
}
|
|
before := string(data)
|
|
if out, err := f.run(t); err != nil {
|
|
t.Fatalf("second install: %v\n%s", err, out)
|
|
}
|
|
data, _ = os.ReadFile(bashrc)
|
|
if string(data) != before {
|
|
t.Fatalf("not idempotent:\n%s", data)
|
|
}
|
|
backups, _ = filepath.Glob(target + ".netssh-backup.*")
|
|
if len(backups) != 1 {
|
|
t.Fatal("unchanged profile backed up again")
|
|
}
|
|
// Execute the generated Bash block to verify paths containing quotes and spaces.
|
|
cmd := exec.Command("bash", "-c", `. "$HOME/.bashrc"; alias j`)
|
|
cmd.Env = f.env
|
|
if out, err := cmd.CombinedOutput(); err != nil || !strings.Contains(string(out), "alias j='netssh'") {
|
|
t.Fatalf("source: %v\n%s", err, out)
|
|
}
|
|
}
|
|
|
|
func TestInstallerRejectsUnverifiedDownloads(t *testing.T) {
|
|
for _, mode := range []string{"missing", "mismatch", "no-entry", "duplicate"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
f := newInstallerFixture(t)
|
|
f.env = append(f.env, "CHECKSUM_MODE="+mode)
|
|
sums := filepath.Join(f.assets, "checksums.txt")
|
|
switch mode {
|
|
case "mismatch":
|
|
writeFixture(t, sums, strings.Repeat("0", 64)+" netssh_linux_amd64\n", 0o644)
|
|
case "no-entry":
|
|
writeFixture(t, sums, strings.Repeat("0", 64)+" other_binary\n", 0o644)
|
|
case "duplicate":
|
|
data, _ := os.ReadFile(sums)
|
|
writeFixture(t, sums, string(data)+string(data), 0o644)
|
|
}
|
|
installed := filepath.Join(f.home, "local bin's", "netssh")
|
|
writeFixture(t, installed, "old binary", 0o755)
|
|
profile := filepath.Join(f.home, ".bashrc")
|
|
writeFixture(t, profile, "old profile\n", 0o600)
|
|
if out, err := f.run(t); err == nil {
|
|
t.Fatalf("accepted invalid checksum: %s", out)
|
|
}
|
|
data, _ := os.ReadFile(installed)
|
|
if string(data) != "old binary" {
|
|
t.Fatal("binary changed")
|
|
}
|
|
data, _ = os.ReadFile(profile)
|
|
if string(data) != "old profile\n" {
|
|
t.Fatal("profile changed")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInstallerRefusesBrokenManagedBlock(t *testing.T) {
|
|
f := newInstallerFixture(t)
|
|
profile := filepath.Join(f.home, ".bashrc")
|
|
original := "export KEEP=yes\n# >>> netssh >>>\nunfinished\n"
|
|
writeFixture(t, profile, original, 0o644)
|
|
if out, err := f.run(t); err == nil {
|
|
t.Fatalf("accepted broken block: %s", out)
|
|
}
|
|
data, _ := os.ReadFile(profile)
|
|
if string(data) != original {
|
|
t.Fatal("profile changed")
|
|
}
|
|
}
|
|
|
|
func TestInstallerUsesShasumAndFindsExistingBinary(t *testing.T) {
|
|
if _, err := exec.LookPath("shasum"); err != nil {
|
|
t.Skip("shasum not available")
|
|
}
|
|
f := newInstallerFixture(t)
|
|
// Hide sha256sum (and Python) as on a standard macOS installation.
|
|
for _, name := range []string{"shasum", "sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "perl"} {
|
|
tool, err := exec.LookPath(name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(tool, filepath.Join(f.bin, name)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
f.env = append(f.env, "PATH="+f.bin, "INSTALL_DIR=")
|
|
installed := filepath.Join(f.bin, "netssh")
|
|
writeFixture(t, installed, "#!/bin/sh\nexit 1\n", 0o755)
|
|
if out, err := f.run(t); err != nil {
|
|
t.Fatalf("shasum update: %v\n%s", err, out)
|
|
}
|
|
data, _ := os.ReadFile(installed)
|
|
expected, _ := os.ReadFile(filepath.Join(f.assets, "netssh_linux_amd64"))
|
|
if string(data) != string(expected) {
|
|
t.Fatal("existing installation was not replaced")
|
|
}
|
|
}
|
|
|
|
func TestInstallerRespectsZdotdir(t *testing.T) {
|
|
f := newInstallerFixture(t)
|
|
zshDir := filepath.Join(f.home, "custom-zsh")
|
|
f.env = append(f.env, "SHELL=/bin/zsh", "ZDOTDIR="+zshDir)
|
|
old := filepath.Join(f.home, ".zshrc")
|
|
writeFixture(t, old, "export KEEP=yes\nnetssh shell-init\n", 0o644)
|
|
if out, err := f.run(t); err != nil {
|
|
t.Fatalf("zsh setup: %v\n%s", err, out)
|
|
}
|
|
data, _ := os.ReadFile(filepath.Join(zshDir, ".zshrc"))
|
|
if strings.Count(string(data), "# >>> netssh >>>") != 1 {
|
|
t.Fatalf("missing custom zsh setup: %s", data)
|
|
}
|
|
data, _ = os.ReadFile(old)
|
|
if string(data) != "export KEEP=yes\n" {
|
|
t.Fatalf("old zsh setup not cleaned: %s", data)
|
|
}
|
|
}
|
|
|
|
func TestInstallerUsesMacOSCacheLayout(t *testing.T) {
|
|
f := newInstallerFixture(t)
|
|
writeFixture(t, filepath.Join(f.bin, "uname"), "#!/bin/sh\ncase \"$1\" in -s) echo Darwin;; -m) echo x86_64;; esac\n", 0o755)
|
|
data, _ := os.ReadFile(filepath.Join(f.assets, "netssh_linux_amd64"))
|
|
writeFixture(t, filepath.Join(f.assets, "checksums.txt"), fmt.Sprintf("%x netssh_darwin_amd64\n", sha256.Sum256(data)), 0o644)
|
|
f.env = append(f.env, "XDG_CACHE_HOME=")
|
|
if out, err := f.run(t); err != nil {
|
|
t.Fatalf("macOS setup: %v\n%s", err, out)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(f.home, "Library", "Caches", "netssh", "aliases.sh")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestInstallerChecksDependenciesBeforeDownloading(t *testing.T) {
|
|
for _, missing := range [][]string{{"awk", "cmp"}, {"curl"}, {"sha256sum", "shasum"}} {
|
|
t.Run(strings.Join(missing, "+"), func(t *testing.T) {
|
|
f := newInstallerFixture(t)
|
|
// Build an isolated PATH containing every dependency except the selected tools.
|
|
for _, name := range []string{"curl", "uname", "sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "sha256sum"} {
|
|
skip := false
|
|
for _, absent := range missing {
|
|
if name == absent {
|
|
skip = true
|
|
}
|
|
}
|
|
if skip {
|
|
if err := os.Remove(filepath.Join(f.bin, name)); err != nil && !os.IsNotExist(err) {
|
|
t.Fatal(err)
|
|
}
|
|
continue
|
|
}
|
|
if _, err := os.Stat(filepath.Join(f.bin, name)); err == nil {
|
|
continue
|
|
}
|
|
path, err := exec.LookPath(name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(path, filepath.Join(f.bin, name)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
marker := filepath.Join(f.assets, "curl-was-called")
|
|
// Replacing curl with a marker makes any premature network operation observable.
|
|
if missing[0] != "curl" {
|
|
writeFixture(t, filepath.Join(f.bin, "curl"), "#!/bin/bash\nprintf called > \"$FIXTURE_ASSETS/curl-was-called\"\nexit 99\n", 0o755)
|
|
}
|
|
f.env = append(f.env, "PATH="+f.bin)
|
|
installed := filepath.Join(f.home, "local bin's", "netssh")
|
|
writeFixture(t, installed, "old binary", 0o755)
|
|
out, err := f.run(t)
|
|
if err == nil {
|
|
t.Fatalf("missing dependencies accepted: %s", out)
|
|
}
|
|
for _, name := range missing {
|
|
if !strings.Contains(out, name) {
|
|
t.Errorf("missing diagnostic for %s: %s", name, out)
|
|
}
|
|
}
|
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
t.Fatal("curl ran before dependency checks completed")
|
|
}
|
|
data, _ := os.ReadFile(installed)
|
|
if string(data) != "old binary" {
|
|
t.Fatal("binary changed")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(f.home, ".bashrc")); !os.IsNotExist(err) {
|
|
t.Fatal("profile created")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInstallerChecksSudoBeforeDownloadingWhenNeeded(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root can write directories regardless of mode bits")
|
|
}
|
|
f := newInstallerFixture(t)
|
|
for _, name := range []string{"sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "sha256sum"} {
|
|
path, err := exec.LookPath(name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(path, filepath.Join(f.bin, name)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
locked := filepath.Join(f.home, "locked")
|
|
if err := os.Mkdir(locked, 0o555); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.Chmod(locked, 0o755) })
|
|
writeFixture(t, filepath.Join(f.bin, "curl"), "#!/bin/bash\nprintf called > \"$FIXTURE_ASSETS/curl-was-called\"\nexit 99\n", 0o755)
|
|
f.env = append(f.env, "PATH="+f.bin, "INSTALL_DIR="+filepath.Join(locked, "bin"))
|
|
out, err := f.run(t)
|
|
if err == nil || !strings.Contains(out, "sudo") {
|
|
t.Fatalf("missing sudo diagnostic: %v\n%s", err, out)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(f.assets, "curl-was-called")); !os.IsNotExist(err) {
|
|
t.Fatal("download ran before sudo check")
|
|
}
|
|
}
|