diff --git a/README.md b/README.md index 70351e4..b50ab12 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + # spritesmith @@ -8,7 +8,7 @@ The client requires an API service and an account token. It does not contain a l ## Install -With Go 1.27.2 or later, install to `~/.local/bin` (add it to your PATH if necessary): +Install a prebuilt Linux or macOS binary (x86_64 or arm64) into `~/.local/bin` (add it to your PATH if necessary). A Go compiler is not required: ```sh curl -fsSL https://spritesmith.ai/install | sh @@ -38,6 +38,12 @@ Generation runs as a server job. The client waits for completion and downloads t The default API is `http://127.0.0.1:8080`; configure your hosted service explicitly. Default output is `spritesmith.png`, or `spritesmith.svg` for SVG downloads. +Source builds remain available for development with a compatible Go toolchain: + +```sh +go install github.com/aviorstudio/spritesmith/cmd/spritesmith@latest +``` + ## Development Install the [komizo release](https://github.com/nicodes/komizo#install) matching `komizo.lock`. diff --git a/cmd/spritesmith/installer_test.go b/cmd/spritesmith/installer_test.go index 3e805f1..5590d7d 100644 --- a/cmd/spritesmith/installer_test.go +++ b/cmd/spritesmith/installer_test.go @@ -3,6 +3,11 @@ package main import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "fmt" "os" "os/exec" "path/filepath" @@ -14,54 +19,109 @@ import ( func TestCurlInstaller(t *testing.T) { _, file, _, _ := runtime.Caller(0) script := filepath.Join(filepath.Dir(file), "../../install.sh") - for _, fail := range []bool{false, true} { - name := "success" - if fail { - name = "failed build preserves existing executable" - } - t.Run(name, func(t *testing.T) { + for _, scenario := range []string{"valid", "checksum", "symlink", "duplicate", "missing", "attestation"} { + t.Run(scenario, func(t *testing.T) { + if scenario == "attestation" && "false" != "true" { + t.Skip("This product verifies release checksums.") + } root := t.TempDir() - bin, fake := filepath.Join(root, "bin"), filepath.Join(root, "commands") - for _, dir := range []string{bin, fake} { + bin := filepath.Join(root, "bin") + fixture := filepath.Join(root, "fixture") + fake := filepath.Join(root, "commands") + for _, dir := range []string{bin, fixture, fake} { if err := os.Mkdir(dir, 0700); err != nil { t.Fatal(err) } } - if err := os.WriteFile(filepath.Join(bin, "spritesmith"), []byte("previous executable\n"), 0700); err != nil { + write := func(path string, body []byte, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, body, mode); err != nil { + t.Fatal(err) + } + } + const tool = "spritesmith" + old := []byte("previous executable\n") + payload := []byte("#!/bin/sh\nprintf 'installed fixture\\n'\n") + write(filepath.Join(bin, tool), old, 0700) + var archive bytes.Buffer + gz := gzip.NewWriter(&archive) + tw := tar.NewWriter(gz) + count := 1 + if scenario == "duplicate" { + count = 2 + } + for i := 0; i < count; i++ { + header := &tar.Header{Name: tool, Mode: 0755, Uid: 1001, Gid: 1001, Size: int64(len(payload)), Typeflag: tar.TypeReg} + if scenario == "symlink" { + header.Typeflag, header.Linkname, header.Size = tar.TypeSymlink, "/outside", 0 + } + if scenario == "missing" { + header.Name = "another-file" + } + if err := tw.WriteHeader(header); err != nil { + t.Fatal(err) + } + if header.Size > 0 { + if _, err := tw.Write(payload); err != nil { + t.Fatal(err) + } + } + } + if err := tw.Close(); err != nil { t.Fatal(err) } - goCommand := `#!/bin/sh -set -eu -[ "$1" = install ] -[ "$2" = 'github.com/aviorstudio/spritesmith/cmd/spritesmith@latest' ] -printf 'new executable\n' > "$GOBIN/spritesmith" -if [ "${INSTALL_BUILD_FAIL:-0}" = 1 ]; then exit 42; fi -` - if err := os.WriteFile(filepath.Join(fake, "go"), []byte(goCommand), 0700); err != nil { + if err := gz.Close(); err != nil { t.Fatal(err) } + write(filepath.Join(fixture, "archive"), archive.Bytes(), 0600) + digest := fmt.Sprintf("%x", sha256.Sum256(archive.Bytes())) + if scenario == "checksum" { + digest = strings.Repeat("0", 64) + } + write(filepath.Join(fixture, "checksums"), []byte(digest+" "+tool+"_Linux_x86_64.tar.gz\n"), 0600) + write(filepath.Join(fake, "uname"), []byte("#!/bin/sh\ncase \"$1\" in -s) echo Linux;; -m) echo x86_64;; *) exit 1;; esac\n"), 0700) + write(filepath.Join(fake, "curl"), []byte(`#!/bin/sh +set -eu +output= +url= +while [ "$#" -gt 0 ]; do + case "$1" in + --output) output=$2; shift 2 ;; + --write-out|--retry|--connect-timeout|--max-time|--proto|--proto-redir) shift 2 ;; + https://*) url=$1; shift ;; + *) shift ;; + esac +done +case "$url" in + https://github.com/aviorstudio/spritesmith/releases/latest) printf 'https://github.com/aviorstudio/spritesmith/releases/tag/v1.2.3' ;; + https://github.com/aviorstudio/spritesmith/releases/download/v1.2.3/checksums.txt) cp "$INSTALL_FIXTURE/checksums" "$output" ;; + https://github.com/aviorstudio/spritesmith/releases/download/v1.2.3/spritesmith_Linux_x86_64.tar.gz) cp "$INSTALL_FIXTURE/archive" "$output" ;; + *) echo "unexpected download: $url" >&2; exit 1 ;; +esac +`), 0700) + write(filepath.Join(fake, "gh"), []byte("#!/bin/sh\n[ \"${INSTALL_ATTEST_FAIL:-0}\" != 1 ]\n"), 0700) cmd := exec.Command("sh", script, "--install-dir", bin) - cmd.Env = append(os.Environ(), "PATH="+fake+string(os.PathListSeparator)+os.Getenv("PATH")) - if fail { - cmd.Env = append(cmd.Env, "INSTALL_BUILD_FAIL=1") + cmd.Env = append(os.Environ(), "PATH="+fake+string(os.PathListSeparator)+os.Getenv("PATH"), "INSTALL_FIXTURE="+fixture) + if scenario == "attestation" { + cmd.Env = append(cmd.Env, "INSTALL_ATTEST_FAIL=1") } output, err := cmd.CombinedOutput() - if fail == (err == nil) { - t.Fatalf("unexpected result: %v\n%s", err, output) + if scenario == "valid" && err != nil { + t.Fatalf("install: %v\n%s", err, output) } - body, err := os.ReadFile(filepath.Join(bin, "spritesmith")) + if scenario != "valid" && err == nil { + t.Fatalf("invalid release accepted: %s", output) + } + got, err := os.ReadFile(filepath.Join(bin, tool)) if err != nil { t.Fatal(err) } - want := "new executable\n" - if fail { - want = "previous executable\n" - } - if string(body) != want { - t.Fatalf("unexpected executable: %q", body) + want := old + if scenario == "valid" { + want = payload } - if !fail && !strings.Contains(string(output), "Installed spritesmith") { - t.Fatalf("missing install receipt: %s", output) + if !bytes.Equal(got, want) { + t.Fatalf("unexpected executable after %s: %q", scenario, got) } }) } diff --git a/install.sh b/install.sh index 2df5b9c..6a7c895 100755 --- a/install.sh +++ b/install.sh @@ -1,9 +1,9 @@ #!/bin/sh -# Install spritesmith using the Go module's normal verification and build process. +# Install a published spritesmith release into a user-owned directory. set -eu tool=spritesmith -module=github.com/aviorstudio/spritesmith/cmd/spritesmith +repo_url=https://github.com/aviorstudio/spritesmith release=latest install_dir=${INSTALL_DIR:-"$HOME/.local/bin"} work_dir= @@ -17,6 +17,7 @@ trap cleanup 0 trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM + while [ "$#" -gt 0 ]; do case "$1" in --version|-v|--install-dir) @@ -27,31 +28,76 @@ while [ "$#" -gt 0 ]; do esac shift 2 ;; --help|-h) - printf 'Install %s with Go: [--version VERSION|latest] [--install-dir /absolute/path]\nDefaults: latest Go module, ~/.local/bin. Requires Go.\n' "$tool" + printf 'Install %s: [--version X.Y.Z|latest] [--install-dir /absolute/path]\nDefaults: latest release, ~/.local/bin.\n' "$tool" exit 0 ;; *) fail "Unknown option: $1" ;; esac done case "$install_dir" in /*) ;; *) fail 'The install directory must be an absolute path.' ;; esac -command -v go >/dev/null 2>&1 || fail 'Install Go first, then run this command again.' -case "$release" in - latest) ;; - v[0-9]*) ;; - [0-9]*) release=v$release ;; - *) fail 'Use latest or a Go module version such as v1.2.3.' ;; +for command in curl tar mktemp awk grep cut cp chmod mv; do + command -v "$command" >/dev/null 2>&1 || fail "Install $command first." +done + +if command -v sha256sum >/dev/null 2>&1; then + checksum_tool=sha256sum +elif command -v shasum >/dev/null 2>&1; then + checksum_tool=shasum +else + fail 'A SHA-256 utility (sha256sum or shasum) is required.' +fi +case "$(uname -s)" in + Linux) platform=Linux ;; + Darwin) platform=Darwin ;; + *) fail 'Supported operating systems: Linux and macOS.' ;; +esac +case "$(uname -m)" in + x86_64|amd64) arch=x86_64 ;; + arm64|aarch64) arch=arm64 ;; + *) fail 'Supported CPU architectures: x86_64 and arm64.' ;; esac +if [ "$platform" = Darwin ] && [ "$arch" = x86_64 ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = 1 ]; then + arch=arm64 +fi +fetch() { + curl --fail --silent --show-error --location --retry 3 \ + --connect-timeout 10 --max-time 300 --proto '=https' --proto-redir '=https' "$@" +} +if [ "$release" = latest ]; then + latest_url=$(fetch --output /dev/null --write-out '%{url_effective}' "$repo_url/releases/latest") || fail 'Could not find the latest release.' + case "$latest_url" in + "$repo_url/releases/tag/"*) release=${latest_url##*/} ;; + *) fail 'No published release was found.' ;; + esac +fi +case "$release" in v*) ;; *) release=v$release ;; esac +printf '%s\n' "$release" | LC_ALL=C grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || fail 'Use a release version such as v1.2.3.' +asset=${tool}_${platform}_${arch}.tar.gz +base=$repo_url/releases/download/$release work_dir=$(mktemp -d "${TMPDIR:-/tmp}/$tool-install.XXXXXXXX") || fail 'Could not create a temporary directory.' -mkdir "$work_dir/bin" -printf 'Building %s from %s@%s...\n' "$tool" "$module" "$release" -GOBIN="$work_dir/bin" go install "$module@$release" || fail 'Go installation failed; the installed binary was not changed.' -[ -f "$work_dir/bin/$tool" ] && [ ! -L "$work_dir/bin/$tool" ] || fail 'Go did not produce a regular executable.' +printf 'Installing %s %s (%s %s)...\n' "$tool" "$release" "$platform" "$arch" +fetch "$base/checksums.txt" --output "$work_dir/checksums.txt" || fail 'Could not download release checksums.' +fetch "$base/$asset" --output "$work_dir/$asset" || fail "Could not download $asset." +expected=$(awk -v name="$asset" '$2 == name || $2 == "*" name { count++; digest=$1 } END { if (count == 1) print digest }' "$work_dir/checksums.txt") +printf '%s\n' "$expected" | LC_ALL=C grep -Eq '^[a-f0-9]{64}$' || fail 'The archive needs exactly one valid release checksum.' +if [ "$checksum_tool" = sha256sum ]; then + actual=$(sha256sum "$work_dir/$asset" | awk '{print $1}') +else + actual=$(shasum -a 256 "$work_dir/$asset" | awk '{print $1}') +fi +[ "$actual" = "$expected" ] || fail 'Checksum verification failed; the installed binary was not changed.' + +# Extract only one regular executable, never the rest of the archive. +entry_type=$(tar -tvzf "$work_dir/$asset" "$tool" | cut -c1) +[ "$entry_type" = '-' ] || fail 'The archive must contain exactly one regular executable.' +tar --no-same-owner -xzf "$work_dir/$asset" -C "$work_dir" "$tool" || fail 'The executable could not be extracted.' +[ -f "$work_dir/$tool" ] && [ ! -L "$work_dir/$tool" ] || fail 'The archive does not contain a regular executable.' mkdir -p "$install_dir" || fail 'Choose a writable install directory.' [ ! -d "$install_dir/$tool" ] || fail 'The install destination is a directory.' stage_dir=$(mktemp -d "$install_dir/.$tool-install.XXXXXXXX") || fail 'Could not stage the executable.' -cp "$work_dir/bin/$tool" "$stage_dir/$tool" +cp "$work_dir/$tool" "$stage_dir/$tool" chmod 755 "$stage_dir/$tool" mv -f "$stage_dir/$tool" "$install_dir/$tool" -printf 'Installed %s at %s/%s\n' "$tool" "$install_dir" "$tool" +printf 'Installed %s %s at %s/%s\n' "$tool" "$release" "$install_dir" "$tool" case ":$PATH:" in *":$install_dir:"*) ;; *) printf 'Add %s to your PATH to run %s by name.\n' "$install_dir" "$tool" ;;