Scaffold ipswap: tray-driven Windows IP preset switcher
Implements the design in claude.md as a building skeleton: pure Go, no cgo, cross-compiles to a single Windows .exe from Linux. Architecture follows the spec's deliberate split. The fast path is native — tray icon, grouped submenus, a Win32 MessageBox showing a live before/after diff, then netsh. The slow path is an embedded web editor served on a random loopback port and opened in the default browser. Two decisions worth recording: Reads use GetAdaptersAddresses, writes use netsh. The spec left the enumeration mechanism open; parsing `netsh show config` breaks on a non-English Windows because the output is localised. DNS static-vs-DHCP origin is not exposed by that API, so it comes from one registry read. The netsh command plan is built in portable code. That puts the delete-every- existing-address step — the one that stops secondary addresses leaking across switches — under test without needing a Windows box. The editor requires the session token in a header for mutations, not just the cookie: any page in the browser can make it POST to 127.0.0.1 with the cookie attached, but it cannot set a header. The updater refuses to install a release that publishes no SHA256. Not yet done: no group picker for export (the API supports it), no single-instance guard, and internal/server/web/app.css is reconstructed from the description in claude.md rather than the canonical apointless.css. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
//go:build !windows
|
||||
|
||||
package updater
|
||||
|
||||
import "errors"
|
||||
|
||||
// InstallAndRestart is Windows-only: the swap helper is a batch file and the
|
||||
// thing being replaced is an .exe.
|
||||
func InstallAndRestart(downloadedExe string) error {
|
||||
return errors.New("in-place update is only supported on Windows")
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//go:build windows
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// swapScript waits for ipswap to exit, replaces the binary, restarts it and
|
||||
// deletes itself.
|
||||
//
|
||||
// A running .exe on Windows is locked against overwriting, so the swap cannot
|
||||
// be done by the process being replaced — it has to outlive it. A tiny batch
|
||||
// file is the least machinery that does this without shipping a second binary.
|
||||
//
|
||||
// %1 is the PID to wait for, %2 the freshly downloaded exe, %3 the exe to
|
||||
// replace. The tasklist/find pair is the standard "is this PID still alive"
|
||||
// idiom; `timeout` rather than `ping -n` keeps it readable.
|
||||
const swapScript = `@echo off
|
||||
setlocal
|
||||
set PID=%~1
|
||||
set NEWEXE=%~2
|
||||
set TARGET=%~3
|
||||
|
||||
rem Give up after ~30s rather than spinning forever if the old process hangs.
|
||||
set /a TRIES=0
|
||||
:wait
|
||||
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
|
||||
if errorlevel 1 goto swap
|
||||
set /a TRIES+=1
|
||||
if %TRIES% GEQ 30 goto giveup
|
||||
timeout /t 1 /nobreak >nul
|
||||
goto wait
|
||||
|
||||
:swap
|
||||
copy /y "%NEWEXE%" "%TARGET%" >nul
|
||||
if errorlevel 1 goto giveup
|
||||
del /q "%NEWEXE%" >nul 2>&1
|
||||
start "" "%TARGET%"
|
||||
goto done
|
||||
|
||||
:giveup
|
||||
rem Leave the download in place so the user can swap it in by hand.
|
||||
exit /b 1
|
||||
|
||||
:done
|
||||
endlocal
|
||||
rem Delete this script last; cmd tolerates a batch file removing itself.
|
||||
del /q "%~f0" >nul 2>&1
|
||||
`
|
||||
|
||||
// InstallAndRestart writes the swap helper, launches it detached, and returns.
|
||||
// The caller must exit promptly afterwards — the helper is already waiting on
|
||||
// this process to go away.
|
||||
//
|
||||
// This is only ever reached from an explicit click on "Update available": the
|
||||
// updater never installs on its own.
|
||||
func InstallAndRestart(downloadedExe string) error {
|
||||
target, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("locating the running executable: %w", err)
|
||||
}
|
||||
target, err = filepath.Abs(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
script := filepath.Join(os.TempDir(), "ipswap-update.cmd")
|
||||
if err := os.WriteFile(script, []byte(swapScript), 0o755); err != nil {
|
||||
return fmt.Errorf("writing the update helper: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("cmd.exe", "/c", script,
|
||||
strconv.Itoa(os.Getpid()), downloadedExe, target)
|
||||
// Detach: the helper has to survive this process exiting, and it must not
|
||||
// flash a console window while it waits.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: windowsCreateNoWindow | windowsDetachedProcess,
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("starting the update helper: %w", err)
|
||||
}
|
||||
return cmd.Process.Release()
|
||||
}
|
||||
|
||||
const (
|
||||
windowsDetachedProcess = 0x00000008
|
||||
windowsCreateNoWindow = 0x08000000
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Version is a parsed semantic version. Only what a release-tag comparison
|
||||
// needs is modelled: major.minor.patch plus an optional pre-release, which
|
||||
// sorts before the same version without one.
|
||||
type Version struct {
|
||||
Major, Minor, Patch int
|
||||
Pre string
|
||||
}
|
||||
|
||||
// ParseVersion accepts "v1.2.0", "1.2.0", "1.2", "1" and "1.2.0-rc1". Build
|
||||
// metadata after "+" is ignored, as semver says it must be for ordering.
|
||||
func ParseVersion(s string) (Version, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
if s == "" {
|
||||
return Version{}, fmt.Errorf("empty version")
|
||||
}
|
||||
if i := strings.IndexByte(s, '+'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
|
||||
var v Version
|
||||
if i := strings.IndexByte(s, '-'); i >= 0 {
|
||||
v.Pre = s[i+1:]
|
||||
s = s[:i]
|
||||
}
|
||||
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) > 3 {
|
||||
return Version{}, fmt.Errorf("%q has too many components", s)
|
||||
}
|
||||
dst := []*int{&v.Major, &v.Minor, &v.Patch}
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 {
|
||||
return Version{}, fmt.Errorf("%q is not a version number", s)
|
||||
}
|
||||
*dst[i] = n
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Compare returns -1, 0 or 1 as v sorts before, equal to, or after other.
|
||||
func (v Version) Compare(other Version) int {
|
||||
for _, pair := range [][2]int{
|
||||
{v.Major, other.Major},
|
||||
{v.Minor, other.Minor},
|
||||
{v.Patch, other.Patch},
|
||||
} {
|
||||
if pair[0] != pair[1] {
|
||||
if pair[0] < pair[1] {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// A pre-release sorts before the release it leads up to.
|
||||
switch {
|
||||
case v.Pre == "" && other.Pre == "":
|
||||
return 0
|
||||
case v.Pre == "":
|
||||
return 1
|
||||
case other.Pre == "":
|
||||
return -1
|
||||
case v.Pre < other.Pre:
|
||||
return -1
|
||||
case v.Pre > other.Pre:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (v Version) String() string {
|
||||
s := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
|
||||
if v.Pre != "" {
|
||||
s += "-" + v.Pre
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// IsNewer reports whether candidate is a strictly later version than current.
|
||||
// An unparseable version on either side means "no update", because offering a
|
||||
// download on the strength of a tag we could not read is worse than silence.
|
||||
func IsNewer(candidate, current string) bool {
|
||||
c, err := ParseVersion(candidate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
cur, err := ParseVersion(current)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return c.Compare(cur) > 0
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package updater
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want Version
|
||||
}{
|
||||
{"v1.2.0", Version{1, 2, 0, ""}},
|
||||
{"1.2.0", Version{1, 2, 0, ""}},
|
||||
{"1.2", Version{1, 2, 0, ""}},
|
||||
{"1", Version{1, 0, 0, ""}},
|
||||
{"v1.2.0-rc1", Version{1, 2, 0, "rc1"}},
|
||||
{"1.2.0+build7", Version{1, 2, 0, ""}},
|
||||
{" v0.1.0 ", Version{0, 1, 0, ""}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := ParseVersion(tc.in)
|
||||
if err != nil {
|
||||
t.Errorf("ParseVersion(%q) errored: %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("ParseVersion(%q) = %+v, want %+v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, in := range []string{"", "v", "abc", "1.2.3.4", "1.x", "-1.0.0"} {
|
||||
if got, err := ParseVersion(in); err == nil {
|
||||
t.Errorf("ParseVersion(%q) = %+v, want error", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNewer(t *testing.T) {
|
||||
yes := [][2]string{
|
||||
{"v1.2.1", "v1.2.0"},
|
||||
{"v1.3.0", "v1.2.9"},
|
||||
{"v2.0.0", "v1.99.99"},
|
||||
{"v1.2.0", "v1.2.0-rc1"},
|
||||
}
|
||||
for _, tc := range yes {
|
||||
if !IsNewer(tc[0], tc[1]) {
|
||||
t.Errorf("IsNewer(%q, %q) = false, want true", tc[0], tc[1])
|
||||
}
|
||||
}
|
||||
|
||||
no := [][2]string{
|
||||
{"v1.2.0", "v1.2.0"},
|
||||
{"v1.2.0", "v1.2.1"},
|
||||
{"v1.2.0-rc1", "v1.2.0"},
|
||||
// An unreadable tag must never trigger an update offer.
|
||||
{"garbage", "v1.2.0"},
|
||||
{"v1.2.0", "garbage"},
|
||||
{"", "v1.0.0"},
|
||||
}
|
||||
for _, tc := range no {
|
||||
if IsNewer(tc[0], tc[1]) {
|
||||
t.Errorf("IsNewer(%q, %q) = true, want false", tc[0], tc[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// Package updater checks a Gitea repository for a newer release and, on
|
||||
// request, downloads and verifies the new binary.
|
||||
//
|
||||
// Two rules shape the whole package: it never installs on its own, and it never
|
||||
// blocks startup. The check runs in a goroutine behind a short timeout and
|
||||
// fails silently into the log, because a laptop on a customer site frequently
|
||||
// has no route to the Gitea host and that must not be a visible error.
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// checkTimeout bounds the startup release check.
|
||||
const checkTimeout = 8 * time.Second
|
||||
|
||||
// maxAssetBytes caps a download. The binary is a few MB; anything near this is
|
||||
// a wrong URL or a captive portal serving a login page.
|
||||
const maxAssetBytes = 128 << 20
|
||||
|
||||
// Release is the subset of Gitea's release JSON that matters here.
|
||||
type Release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
Assets []Asset `json:"assets"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// Asset is one file attached to a release.
|
||||
type Asset struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
// Checker polls one repository.
|
||||
type Checker struct {
|
||||
// Repo is the repository as "https://gitea.example.com/owner/repo".
|
||||
Repo string
|
||||
// Current is the compiled-in version, from -X main.version.
|
||||
Current string
|
||||
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// Update describes an available newer release.
|
||||
type Update struct {
|
||||
Version string
|
||||
Release Release
|
||||
Asset Asset
|
||||
// SHA256 is the expected checksum, empty if the release published none.
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// apiURL turns a repo browse URL into its releases/latest API endpoint.
|
||||
func apiURL(repo string) (string, error) {
|
||||
repo = strings.TrimSuffix(strings.TrimSpace(repo), "/")
|
||||
if repo == "" {
|
||||
return "", fmt.Errorf("no update repository configured")
|
||||
}
|
||||
u, err := url.Parse(repo)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("update repo %q is not a URL: %w", repo, err)
|
||||
}
|
||||
if u.Scheme != "https" && u.Scheme != "http" {
|
||||
return "", fmt.Errorf("update repo %q must be an http(s) URL", repo)
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", fmt.Errorf("update repo %q should look like https://host/owner/repo", repo)
|
||||
}
|
||||
|
||||
u.Path = path.Join("/api/v1/repos", parts[0], parts[1], "releases/latest")
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c *Checker) client() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
return &http.Client{Timeout: checkTimeout}
|
||||
}
|
||||
|
||||
// Check asks for the latest release and returns an Update if it is newer than
|
||||
// the running build. A nil Update with a nil error means "up to date".
|
||||
func (c *Checker) Check(ctx context.Context) (*Update, error) {
|
||||
endpoint, err := apiURL(c.Repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, checkTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.client().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contacting %s: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned %s", endpoint, resp.Status)
|
||||
}
|
||||
|
||||
var rel Release
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&rel); err != nil {
|
||||
return nil, fmt.Errorf("parsing the release response: %w", err)
|
||||
}
|
||||
|
||||
if !IsNewer(rel.TagName, c.Current) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
asset, ok := pickExe(rel.Assets)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("release %s has no .exe asset", rel.TagName)
|
||||
}
|
||||
|
||||
return &Update{
|
||||
Version: strings.TrimPrefix(rel.TagName, "v"),
|
||||
Release: rel,
|
||||
Asset: asset,
|
||||
SHA256: findChecksum(rel, asset.Name),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pickExe(assets []Asset) (Asset, bool) {
|
||||
for _, a := range assets {
|
||||
if strings.HasSuffix(strings.ToLower(a.Name), ".exe") {
|
||||
return a, true
|
||||
}
|
||||
}
|
||||
return Asset{}, false
|
||||
}
|
||||
|
||||
var sha256Re = regexp.MustCompile(`\b([a-fA-F0-9]{64})\b`)
|
||||
|
||||
// findChecksum digs the expected SHA256 out of the release body. The body is
|
||||
// scanned for a line naming the asset; a bare 64-hex string anywhere is
|
||||
// accepted as a fallback for a release that publishes only the one checksum.
|
||||
//
|
||||
// A SHA256SUMS sibling asset is handled by the caller, which can fetch it — it
|
||||
// is not available from the release JSON alone.
|
||||
func findChecksum(rel Release, assetName string) string {
|
||||
for _, line := range strings.Split(rel.Body, "\n") {
|
||||
if strings.Contains(line, assetName) {
|
||||
if m := sha256Re.FindStringSubmatch(line); m != nil {
|
||||
return strings.ToLower(m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
if m := sha256Re.FindStringSubmatch(rel.Body); m != nil {
|
||||
return strings.ToLower(m[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SumsAsset finds a SHA256SUMS-style asset in the release, if there is one.
|
||||
func (u *Update) SumsAsset() (Asset, bool) {
|
||||
for _, a := range u.Release.Assets {
|
||||
n := strings.ToUpper(a.Name)
|
||||
if strings.Contains(n, "SHA256") {
|
||||
return a, true
|
||||
}
|
||||
}
|
||||
return Asset{}, false
|
||||
}
|
||||
|
||||
// Download fetches the update's .exe into dir and verifies its SHA256 before
|
||||
// returning the path. A release with no published checksum is refused: an
|
||||
// unverified binary that we are about to swap in and run is not worth the
|
||||
// convenience.
|
||||
func (c *Checker) Download(ctx context.Context, u *Update, dir string) (string, error) {
|
||||
want := u.SHA256
|
||||
if want == "" {
|
||||
// Try the sibling SHA256SUMS asset before giving up.
|
||||
if sums, ok := u.SumsAsset(); ok {
|
||||
body, err := c.fetch(ctx, sums.BrowserDownloadURL, 1<<20)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetching %s: %w", sums.Name, err)
|
||||
}
|
||||
want = checksumFor(string(body), u.Asset.Name)
|
||||
}
|
||||
}
|
||||
if want == "" {
|
||||
return "", fmt.Errorf("release %s publishes no SHA256 for %s; refusing to install an unverified binary", u.Release.TagName, u.Asset.Name)
|
||||
}
|
||||
|
||||
body, err := c.fetch(ctx, u.Asset.BrowserDownloadURL, maxAssetBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("downloading %s: %w", u.Asset.Name, err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(body)
|
||||
got := hex.EncodeToString(sum[:])
|
||||
if !strings.EqualFold(got, want) {
|
||||
return "", fmt.Errorf("checksum mismatch for %s:\n expected %s\n got %s", u.Asset.Name, want, got)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dest := filepath.Join(dir, u.Asset.Name)
|
||||
if err := os.WriteFile(dest, body, 0o755); err != nil {
|
||||
return "", fmt.Errorf("writing %s: %w", dest, err)
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func (c *Checker) fetch(ctx context.Context, rawURL string, limit int64) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.client().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("server returned %s", resp.Status)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, limit))
|
||||
}
|
||||
|
||||
// checksumFor pulls one file's hash out of a `sha256sum` style listing.
|
||||
func checksumFor(sums, name string) string {
|
||||
for _, line := range strings.Split(sums, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
// The name may carry sha256sum's binary-mode "*" prefix.
|
||||
if strings.TrimPrefix(fields[len(fields)-1], "*") == name {
|
||||
if sha256Re.MatchString(fields[0]) {
|
||||
return strings.ToLower(fields[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPIURL(t *testing.T) {
|
||||
got, err := apiURL("https://gitea.apointless.space/bsncubed/ipswap")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "https://gitea.apointless.space/api/v1/repos/bsncubed/ipswap/releases/latest"
|
||||
if got != want {
|
||||
t.Errorf("apiURL = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if _, err := apiURL("https://gitea.apointless.space/bsncubed/ipswap/"); err != nil {
|
||||
t.Errorf("a trailing slash should be tolerated: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"", "not a url", "https://host", "https://host/a/b/c"} {
|
||||
if _, err := apiURL(bad); err == nil {
|
||||
t.Errorf("apiURL(%q) should have failed", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckFindsNewerRelease(t *testing.T) {
|
||||
rel := Release{
|
||||
TagName: "v1.2.0",
|
||||
Body: "ipswap.exe e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n",
|
||||
Assets: []Asset{
|
||||
{Name: "SHA256SUMS", BrowserDownloadURL: "http://x/sums"},
|
||||
{Name: "ipswap.exe", BrowserDownloadURL: "http://x/ipswap.exe"},
|
||||
},
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(rel)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Checker{Repo: srv.URL + "/owner/repo", Current: "v1.1.0"}
|
||||
up, err := c.Check(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if up == nil {
|
||||
t.Fatal("expected an update, got nil")
|
||||
}
|
||||
if up.Version != "1.2.0" {
|
||||
t.Errorf("version = %q, want 1.2.0", up.Version)
|
||||
}
|
||||
if up.Asset.Name != "ipswap.exe" {
|
||||
t.Errorf("picked asset %q, want ipswap.exe", up.Asset.Name)
|
||||
}
|
||||
if up.SHA256 != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" {
|
||||
t.Errorf("checksum = %q", up.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckIgnoresOlderRelease(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(Release{TagName: "v1.0.0"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Checker{Repo: srv.URL + "/owner/repo", Current: "v1.2.0"}
|
||||
up, err := c.Check(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if up != nil {
|
||||
t.Errorf("expected no update, got %+v", up)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksumFor(t *testing.T) {
|
||||
sums := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ipswap.exe\n" +
|
||||
"aaaabbbbccccddddeeeeffff00001111222233334444555566667777888899990 other.zip\n"
|
||||
got := checksumFor(sums, "ipswap.exe")
|
||||
if got != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" {
|
||||
t.Errorf("checksumFor = %q", got)
|
||||
}
|
||||
if checksumFor(sums, "missing.exe") != "" {
|
||||
t.Error("expected empty checksum for an absent file")
|
||||
}
|
||||
// sha256sum's binary-mode marker must not defeat the lookup.
|
||||
if checksumFor("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 *ipswap.exe", "ipswap.exe") == "" {
|
||||
t.Error("binary-mode '*' prefix should be tolerated")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user