Files
Anitrack/updater.go
T
John O'Keefe 4f36457563 fix(updater): probe swap path before enabling self-updates
The 2.0.0 update helper renames staged files from os.TempDir() onto the
running executable's directory. On the common Arch/Fedora layout where
/tmp is tmpfs and $HOME is persistent, that rename crosses filesystems
and fails with EXDEV ("invalid cross-device link"). The helper's
recovery path then deletes the working binary while failing to restore
it: twenty observed attempts, an orphaned .bak, and no running app.

Guard against this at startup instead of mid-swap:

- swapPathProven() exercises the exact move the helper will perform
  (os.Rename from os.TempDir() into the executable's directory) with a
  throwaway probe file, then cleans up after itself.
- maybeEnableUpdater() skips the updater entirely when the probe fails
  or the build version is empty, logging the reason. A loud skip beats
  a destructive attempt that can strand the user with no app.
- renameProbeOK() is the testable core, taking explicit tmp/target dirs.
- updater_probe_test.go covers same-filesystem success with no leftover
  files, and fail-closed behavior for missing tmp dir, missing target
  dir, and unwritable target dir.
- V3_MIGRATION.md documents the probe and the incident that motivated
  it.
2026-09-22 13:02:00 -04:00

119 lines
3.7 KiB
Go

package main
import (
"context"
_ "embed"
"fmt"
"log"
"os"
"path/filepath"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/updater"
giteaprovider "AniTrack/updater/gitea"
)
//go:embed updater.pub
var updaterPublicKey []byte
// swapPathProven verifies the exact move the update helper will perform:
// a rename from the framework staging area (os.TempDir, where it creates
// wails-update-*) onto the running executable's directory. A bare Rename
// fails across filesystems (EXDEV: tmpfs /tmp vs persistent $HOME is the
// Arch/Fedora default), and the helper's recovery path then deletes the
// working binary while failing to restore it — stranding the user with no
// app. If this probe fails we skip updates entirely: a loud skip beats a
// destructive attempt. Observed in the wild on 2.0.0 (helper log: twenty
// "invalid cross-device link" attempts, orphaned .bak, nothing running).
func swapPathProven() error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("cannot locate executable: %w", err)
}
return renameProbeOK(os.TempDir(), filepath.Dir(exe))
}
// renameProbeOK proves a file can be renamed from tmpDir into targetDir and
// cleans up after itself. It exercises the same call (os.Rename) across the
// same two directories the helper will use, so any EXDEV or permission
// failure surfaces here, harmlessly, instead of mid-swap.
func renameProbeOK(tmpDir, targetDir string) error {
tmp, err := os.CreateTemp(tmpDir, "anitrack-update-probe-*")
if err != nil {
return fmt.Errorf("cannot stage update files: %w", err)
}
tmpName := tmp.Name()
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return fmt.Errorf("cannot stage update files: %w", err)
}
probe := filepath.Join(targetDir, ".anitrack-update-probe")
if err := os.Rename(tmpName, probe); err != nil {
os.Remove(tmpName)
return fmt.Errorf("cannot move update into %s: %w", targetDir, err)
}
if err := os.Remove(probe); err != nil {
return fmt.Errorf("cannot clean up probe in %s: %w", targetDir, err)
}
return nil
}
// maybeEnableUpdater wires self-updates: desktop production builds only.
// Mobile stays on Obtainium; dev builds never phone home. The startup check
// is headless — the builtin window opens only when an update is found, via a
// second CheckAndInstall (one redundant index round-trip, negligible).
func maybeEnableUpdater(app *application.App) {
if !updaterAutoCheck {
return
}
if !application.System.IsDesktop() {
return
}
if appVersion() == "" {
log.Println("updater: disabled: empty version")
return
}
if err := swapPathProven(); err != nil {
log.Printf("updater: disabled: %s", err)
return
}
gh, err := giteaprovider.New(giteaprovider.Config{
BaseURL: "https://git.linuxhg.com",
Owner: "john-okeefe",
Repo: "Anitrack",
AssetName: "AniTrack-linux-amd64",
// Beta channel (rc tags) for testing: ANITRACK_UPDATER_CHANNEL=beta.
AllowPrerelease: os.Getenv("ANITRACK_UPDATER_CHANNEL") == "beta",
})
if err != nil {
log.Printf("updater: %s", err)
return
}
if err := app.Updater.Init(updater.Config{
CurrentVersion: appVersion(),
Providers: []updater.Provider{gh},
PublicKey: updaterPublicKey,
Window: &updater.BuiltinWindow{
CSS: ":root { --accent: #4d9fff; }",
},
}); err != nil {
log.Printf("updater: init: %s", err)
return
}
go func() {
rel, err := app.Updater.Check(context.Background())
if err != nil {
log.Printf("updater: check: %s", err)
return
}
if rel == nil {
return
}
log.Printf("updater: %s available, opening installer", rel.Version)
if err := app.Updater.CheckAndInstall(context.Background()); err != nil {
log.Printf("updater: install: %s", err)
}
}()
}