//go:build selfupdate_qualification // Command qualify-selfupdate exercises MPV Manager's portable updater on the // native operating system without touching a real installation. It is a // development/release qualification tool and is not included in release // packages. package main import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "io" "net" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/version" "lukechampine.com/blake3" ) const ( qualificationTimeout = 60 * time.Second qualificationKeyID = "native-qualification-only" capabilityFileName = ".qualifier-capability" ) type options struct { oldBinary string newBinary string keep bool version bool jsonVersion bool helper string driverCase string updateURL string secondary string resultPath string rootPath string capability string postUpdate string postRestart string expected version.BinaryIdentity expectedLen int64 expectedSum string } type driverResult struct { Case string `json:"case"` JournalPath string `json:"journal_path,omitempty"` State string `json:"state"` } type qualificationResult struct { Case string `json:"case"` State string `json:"state"` Duration string `json:"duration"` } func main() { opts, err := parseOptions(os.Args[1:]) if err != nil { fatal(err) } switch { case opts.helper != "": if err := version.RunUpdateHelper(opts.helper); err != nil { fatal(err) } case opts.version: if opts.jsonVersion { if err := json.NewEncoder(os.Stdout).Encode(version.GetBinaryIdentity()); err != nil { fatal(err) } return } fmt.Println(version.GetCurrentVersion()) case opts.postRestart != "": if err := runPostUpdate(opts); err != nil { fatal(err) } case opts.driverCase != "": if err := runDriver(opts); err != nil { fatal(err) } default: if err := runQualification(opts); err != nil { fatal(err) } } } func parseOptions(arguments []string) (options, error) { var opts options flags := flag.NewFlagSet("qualify-selfupdate", flag.ContinueOnError) flags.SetOutput(io.Discard) flags.StringVar(&opts.oldBinary, "old", "", "synthetic old-version qualifier binary") flags.StringVar(&opts.newBinary, "new", "", "synthetic new-version qualifier binary") flags.BoolVar(&opts.keep, "keep", false, "retain the disposable qualification directory") flags.BoolVar(&opts.version, "version", false, "print qualifier binary version") flags.BoolVar(&opts.jsonVersion, "json", false, "print machine-readable identity with --version") flags.StringVar(&opts.helper, "update-helper", "", "internal updater helper journal") flags.StringVar(&opts.driverCase, "qualifier-driver", "", "internal native qualification case") flags.StringVar(&opts.updateURL, "qualifier-url", "", "internal update payload URL") flags.StringVar(&opts.secondary, "qualifier-secondary", "", "internal secondary target") flags.StringVar(&opts.resultPath, "qualifier-result", "", "internal driver result path") flags.StringVar(&opts.rootPath, "qualifier-root", "", "internal controller-owned disposable root") flags.StringVar(&opts.capability, "qualifier-capability", "", "internal controller capability") flags.StringVar(&opts.postUpdate, "post-update", "", "internal update health transaction") flags.StringVar(&opts.postRestart, "post-update-restart", "", "internal post-update restart mode") flags.StringVar(&opts.expected.Product, "qualifier-product", "", "internal expected product") flags.StringVar(&opts.expected.Component, "qualifier-component", "", "internal expected component") flags.StringVar(&opts.expected.Version, "qualifier-version", "", "internal expected version") flags.StringVar(&opts.expected.GOOS, "qualifier-goos", "", "internal expected operating system") flags.StringVar(&opts.expected.GOARCH, "qualifier-goarch", "", "internal expected architecture") flags.Int64Var(&opts.expectedLen, "qualifier-size", 0, "internal expected payload size") flags.StringVar(&opts.expectedSum, "qualifier-blake3", "", "internal expected payload digest") if err := flags.Parse(arguments); err != nil { return options{}, err } if opts.jsonVersion && !opts.version { return options{}, errors.New("--json requires --version") } return opts, nil } func runQualification(opts options) error { if opts.oldBinary == "" || opts.newBinary == "" { return errors.New("usage: qualify-selfupdate -old -new [-keep]") } oldPath, err := filepath.Abs(opts.oldBinary) if err != nil { return err } newPath, err := filepath.Abs(opts.newBinary) if err != nil { return err } oldIdentity, err := readIdentity(oldPath) if err != nil { return fmt.Errorf("read old qualifier identity: %w", err) } newIdentity, err := readIdentity(newPath) if err != nil { return fmt.Errorf("read new qualifier identity: %w", err) } if err := validateFixtureIdentities(oldIdentity, newIdentity); err != nil { return err } payload, err := os.ReadFile(newPath) if err != nil { return fmt.Errorf("read new qualifier: %w", err) } sum := blake3.Sum256(payload) digest := fmt.Sprintf("blake3:%x", sum) server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.Header().Set("Content-Type", "application/octet-stream") response.Header().Set("Content-Length", fmt.Sprint(len(payload))) _, _ = response.Write(payload) })) defer server.Close() root, err := os.MkdirTemp("", "mpv-manager-native-update-") if err != nil { return err } capability, err := createQualificationCapability(root) if err != nil { _ = os.RemoveAll(root) return err } passed := false defer func() { if passed && !opts.keep { _ = os.RemoveAll(root) } else { fmt.Fprintln(os.Stderr, "Qualification artifacts:", root) } }() results := make([]qualificationResult, 0, 7) for _, caseName := range []string{"hash-reject", "length-reject", "commit", "rollback", "lock", "relaunch", "relaunch-rollback"} { started := time.Now() state, caseErr := runCase(root, capability, caseName, oldPath, newPath, server.URL, int64(len(payload)), digest, newIdentity) if caseErr != nil { return fmt.Errorf("%s case failed: %w", caseName, caseErr) } results = append(results, qualificationResult{Case: caseName, State: state, Duration: time.Since(started).Round(time.Millisecond).String()}) } passed = true report := struct { GOOS string `json:"goos"` GOARCH string `json:"goarch"` OldVersion string `json:"old_version"` NewVersion string `json:"new_version"` Cases []qualificationResult `json:"cases"` }{runtime.GOOS, runtime.GOARCH, oldIdentity.Version, newIdentity.Version, results} encoded, err := json.MarshalIndent(report, "", " ") if err != nil { return err } fmt.Println(string(encoded)) return nil } func runCase(root, capability, caseName, oldPath, newPath, updateURL string, expectedLen int64, expectedSum string, expected version.BinaryIdentity) (string, error) { directory := filepath.Join(root, caseName) if err := os.Mkdir(directory, 0700); err != nil { return "", err } primary := filepath.Join(directory, executableName("primary")) secondary := filepath.Join(directory, "secondary", executableName("mpv-manager")) if err := os.Mkdir(filepath.Dir(secondary), 0700); err != nil { return "", err } if err := copyExecutable(oldPath, primary); err != nil { return "", err } if err := copyExecutable(oldPath, secondary); err != nil { return "", err } configHome := filepath.Join(directory, "config-home") if err := os.Mkdir(configHome, 0700); err != nil { return "", err } resultPath := filepath.Join(directory, "driver-result.json") if caseName == "hash-reject" { expectedSum = "blake3:" + strings.Repeat("0", 64) } if caseName == "length-reject" { expectedLen++ } arguments := []string{ "--qualifier-driver=" + caseName, "--qualifier-url=" + updateURL, "--qualifier-secondary=" + secondary, "--qualifier-result=" + resultPath, "--qualifier-root=" + root, "--qualifier-capability=" + capability, "--qualifier-product=" + expected.Product, "--qualifier-component=" + expected.Component, "--qualifier-version=" + expected.Version, "--qualifier-goos=" + expected.GOOS, "--qualifier-goarch=" + expected.GOARCH, fmt.Sprintf("--qualifier-size=%d", expectedLen), "--qualifier-blake3=" + expectedSum, } driver := exec.Command(primary, arguments...) driver.Env = isolatedEnvironment(configHome) output, err := driver.CombinedOutput() if err != nil { return "", fmt.Errorf("driver exited with %v: %s", err, strings.TrimSpace(string(output))) } result, err := loadDriverResult(resultPath) if err != nil { return "", err } if caseName == "lock" { if result.State != "lock_rejected" { return "", fmt.Errorf("unexpected driver state %q", result.State) } return result.State, verifyFiles([]string{primary, secondary}, oldPath) } if caseName == "hash-reject" || caseName == "length-reject" { if result.State != "payload_rejected" { return "", fmt.Errorf("unexpected rejection state %q", result.State) } return result.State, verifyFiles([]string{primary, secondary}, oldPath) } if result.JournalPath == "" { return "", errors.New("driver did not report an update journal") } wantedState := version.UpdateTransactionCommitted if caseName == "rollback" || caseName == "relaunch-rollback" { wantedState = version.UpdateTransactionRolledBack } journal, err := waitForJournalState(result.JournalPath, wantedState, qualificationTimeout) if err != nil { return "", err } // The helper persists its terminal journal state immediately before its // final cleanup and process exit. Give that short tail time to finish before // the controller removes the disposable tree (notably on Windows, where a // running executable cannot be deleted). time.Sleep(300 * time.Millisecond) if caseName == "commit" || caseName == "relaunch" { if err := verifyFiles([]string{primary, secondary}, newPath); err != nil { return "", err } for _, target := range journal.Targets { if target.Outcome != "committed" { return "", fmt.Errorf("%s target outcome is %q", target.Role, target.Outcome) } } return string(journal.State), nil } if err := verifyFiles([]string{primary, secondary}, oldPath); err != nil { return "", err } if len(journal.Targets) != 2 { return "", fmt.Errorf("rollback journal has %d targets, want 2", len(journal.Targets)) } if caseName == "rollback" && (journal.Targets[0].Outcome != "rolled_back" || journal.Targets[1].Outcome != "failed") { return "", fmt.Errorf("unexpected partial-apply rollback target outcomes: %+v", journal.Targets) } if caseName == "relaunch-rollback" && (journal.Targets[0].Outcome != "rolled_back" || journal.Targets[1].Outcome != "rolled_back") { return "", fmt.Errorf("unexpected rollback target outcomes: %+v", journal.Targets) } return string(journal.State), nil } func runDriver(opts options) error { if opts.resultPath == "" || opts.secondary == "" || opts.updateURL == "" || opts.expectedLen <= 0 || opts.expectedSum == "" || opts.rootPath == "" || opts.capability == "" { return errors.New("incomplete internal qualifier driver arguments") } if opts.driverCase != "hash-reject" && opts.driverCase != "length-reject" && opts.driverCase != "commit" && opts.driverCase != "rollback" && opts.driverCase != "lock" && opts.driverCase != "relaunch" && opts.driverCase != "relaunch-rollback" { return fmt.Errorf("unsupported qualifier case %q", opts.driverCase) } executablePath, err := os.Executable() if err != nil { return err } if err := validateDriverContainment(opts, executablePath); err != nil { return err } if err := config.SetManagerBinPath(opts.secondary); err != nil { return fmt.Errorf("configure isolated secondary target: %w", err) } check := &version.VersionCheckResult{ UpdateAvailable: true, LatestVersion: opts.expected.Version, URL: opts.updateURL, BLAKE3: opts.expectedSum, AssetSize: opts.expectedLen, ManifestKeyID: qualificationKeyID, } version.AuthenticateQualificationUpdateSelection(check) prepared, err := version.PrepareSelfUpdateFromCheck(executablePath, check, nil) if opts.driverCase == "hash-reject" || opts.driverCase == "length-reject" { if err == nil { _ = prepared.Abort() return errors.New("invalid update payload was unexpectedly prepared") } return writeDriverResult(opts.resultPath, driverResult{Case: opts.driverCase, State: "payload_rejected"}) } if err != nil { return err } if opts.driverCase == "lock" { _, secondErr := version.PrepareSelfUpdateFromCheck(executablePath, check, nil) if !errors.Is(secondErr, version.ErrUpdateInProgress) { _ = prepared.Abort() return fmt.Errorf("second update attempt returned %v, want ErrUpdateInProgress", secondErr) } if err := prepared.Abort(); err != nil { return err } return writeDriverResult(opts.resultPath, driverResult{Case: opts.driverCase, State: "lock_rejected"}) } journal, err := readJournal(prepared.JournalPath) if err != nil { _ = prepared.Abort() return err } if len(journal.Targets) != 2 { _ = prepared.Abort() return fmt.Errorf("prepared %d targets, want 2", len(journal.Targets)) } if opts.driverCase == "rollback" { var secondaryStage string for _, target := range journal.Targets { if target.Role == "secondary" { secondaryStage = target.StagedPath } } if secondaryStage == "" { _ = prepared.Abort() return errors.New("prepared journal has no secondary target") } if err := requirePathWithinRoot(opts.rootPath, secondaryStage, true); err != nil { _ = prepared.Abort() return fmt.Errorf("refuse out-of-root secondary stage: %w", err) } if err := os.Remove(secondaryStage); err != nil { _ = prepared.Abort() return fmt.Errorf("sabotage secondary stage: %w", err) } } if err := writeDriverResult(opts.resultPath, driverResult{Case: opts.driverCase, JournalPath: prepared.JournalPath, State: "helper_started"}); err != nil { _ = prepared.Abort() return err } relaunchMode := "none" if opts.driverCase == "relaunch" || opts.driverCase == "relaunch-rollback" { relaunchMode = "tui" } if opts.driverCase == "relaunch-rollback" { if err := os.Setenv("MPV_MANAGER_QUALIFIER_RELAUNCH", "fail"); err != nil { _ = prepared.Abort() return err } } if err := prepared.LaunchUpdateHelper(relaunchMode); err != nil { _ = prepared.Abort() return err } return nil } func createQualificationCapability(root string) (string, error) { secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { return "", fmt.Errorf("generate qualifier capability: %w", err) } capability := hex.EncodeToString(secret) path := filepath.Join(root, capabilityFileName) if err := os.WriteFile(path, []byte(capability), 0o600); err != nil { return "", fmt.Errorf("write qualifier capability: %w", err) } return capability, nil } func validateDriverContainment(opts options, executablePath string) error { root, err := filepath.Abs(opts.rootPath) if err != nil { return fmt.Errorf("resolve qualifier root: %w", err) } info, err := os.Lstat(root) if err != nil { return fmt.Errorf("inspect qualifier root: %w", err) } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return errors.New("qualifier root is not a real directory") } capabilityPath := filepath.Join(root, capabilityFileName) capabilityInfo, err := os.Lstat(capabilityPath) if err != nil { return fmt.Errorf("inspect qualifier capability: %w", err) } if !capabilityInfo.Mode().IsRegular() || capabilityInfo.Mode()&os.ModeSymlink != 0 { return errors.New("qualifier capability is not a regular file") } capability, err := os.ReadFile(capabilityPath) if err != nil { return fmt.Errorf("read qualifier capability: %w", err) } if string(capability) != opts.capability { return errors.New("invalid qualifier capability") } if opts.expected.Product != version.ProductID || opts.expected.Component != version.ComponentID || opts.expected.GOOS != runtime.GOOS || opts.expected.GOARCH != runtime.GOARCH || opts.expected.Version == "" { return errors.New("qualifier expected identity does not match the native portable manager") } for _, candidate := range []struct { name string path string mayNotExist bool }{ {name: "driver executable", path: executablePath}, {name: "secondary target", path: opts.secondary}, {name: "result", path: opts.resultPath, mayNotExist: true}, } { if err := requirePathWithinRoot(root, candidate.path, candidate.mayNotExist); err != nil { return fmt.Errorf("%s is not contained: %w", candidate.name, err) } } parsedURL, err := url.Parse(opts.updateURL) if err != nil || parsedURL.Scheme != "http" || parsedURL.Hostname() == "" { return errors.New("qualifier update URL must be a loopback HTTP URL") } host := parsedURL.Hostname() ip := net.ParseIP(host) if host != "localhost" && (ip == nil || !ip.IsLoopback()) { return errors.New("qualifier update URL must be loopback-only") } return nil } func requirePathWithinRoot(root, candidate string, mayNotExist bool) error { resolvedRoot, err := filepath.EvalSymlinks(root) if err != nil { return err } resolvedCandidate, err := filepath.EvalSymlinks(candidate) if err != nil && mayNotExist && os.IsNotExist(err) { parent, parentErr := filepath.EvalSymlinks(filepath.Dir(candidate)) if parentErr != nil { return parentErr } resolvedCandidate = filepath.Join(parent, filepath.Base(candidate)) } else if err != nil { return err } relative, err := filepath.Rel(resolvedRoot, resolvedCandidate) if err != nil { return err } if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { return errors.New("path escapes qualifier root") } return nil } func runPostUpdate(opts options) error { if opts.postRestart != "tui" { return fmt.Errorf("unsupported post-update restart mode %q", opts.postRestart) } if opts.postUpdate == "" { // The helper relaunches the previous binary without a journal after a // failed health check. Exiting successfully is enough for this isolated // qualifier; the real application proceeds into its TUI. return nil } if os.Getenv("MPV_MANAGER_QUALIFIER_RELAUNCH") == "fail" { return errors.New("intentional post-update health failure") } if err := version.AcknowledgeSelfUpdate(opts.postUpdate); err != nil { return err } // Stay alive beyond the updater's stabilization interval, matching the // real TUI process remaining active after acknowledgement. time.Sleep(3 * time.Second) return nil } func validateFixtureIdentities(oldIdentity, newIdentity version.BinaryIdentity) error { if oldIdentity.Product != version.ProductID || oldIdentity.Component != version.ComponentID || newIdentity.Product != version.ProductID || newIdentity.Component != version.ComponentID { return errors.New("qualifier binaries do not report the portable manager identity") } if oldIdentity.GOOS != runtime.GOOS || newIdentity.GOOS != runtime.GOOS || oldIdentity.GOARCH != runtime.GOARCH || newIdentity.GOARCH != runtime.GOARCH { return fmt.Errorf("qualifier binaries must match native %s/%s", runtime.GOOS, runtime.GOARCH) } if oldIdentity.Version == "" || newIdentity.Version == "" || oldIdentity.Version == newIdentity.Version { return errors.New("qualifier binaries must have distinct non-empty versions") } return nil } func readIdentity(path string) (version.BinaryIdentity, error) { output, err := exec.Command(path, "--version", "--json").CombinedOutput() if err != nil { return version.BinaryIdentity{}, fmt.Errorf("%v: %s", err, strings.TrimSpace(string(output))) } var identity version.BinaryIdentity decoder := json.NewDecoder(bytes.NewReader(output)) decoder.DisallowUnknownFields() if err := decoder.Decode(&identity); err != nil { return version.BinaryIdentity{}, err } return identity, nil } func readJournal(path string) (*version.UpdateJournal, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var journal version.UpdateJournal if err := json.Unmarshal(data, &journal); err != nil { return nil, err } return &journal, nil } func waitForJournalState(path string, wanted version.UpdateTransactionState, timeout time.Duration) (*version.UpdateJournal, error) { deadline := time.Now().Add(timeout) var lastState version.UpdateTransactionState for time.Now().Before(deadline) { journal, err := readJournal(path) if err == nil { lastState = journal.State if journal.State == wanted { return journal, nil } if journal.State == version.UpdateTransactionFailed || (journal.State == version.UpdateTransactionRolledBack && wanted != version.UpdateTransactionRolledBack) { return nil, fmt.Errorf("transaction reached %s: %s", journal.State, journal.Error) } } else if !os.IsNotExist(err) { return nil, err } time.Sleep(100 * time.Millisecond) } return nil, fmt.Errorf("timed out waiting for %s; last state was %s", wanted, lastState) } func writeDriverResult(path string, result driverResult) error { data, err := json.Marshal(result) if err != nil { return err } temporary := path + ".tmp" if err := os.WriteFile(temporary, data, 0600); err != nil { return err } return os.Rename(temporary, path) } func loadDriverResult(path string) (driverResult, error) { data, err := os.ReadFile(path) if err != nil { return driverResult{}, err } var result driverResult if err := json.Unmarshal(data, &result); err != nil { return driverResult{}, err } return result, nil } func isolatedEnvironment(configHome string) []string { environment := make([]string, 0, len(os.Environ())+5) for _, entry := range os.Environ() { name, _, _ := strings.Cut(entry, "=") switch strings.ToUpper(name) { case "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME": continue } environment = append(environment, entry) } return append(environment, "HOME="+configHome, "USERPROFILE="+configHome, "APPDATA="+configHome, "LOCALAPPDATA="+configHome, "XDG_CONFIG_HOME="+configHome, ) } func verifyFiles(paths []string, reference string) error { want, err := os.ReadFile(reference) if err != nil { return err } for _, path := range paths { got, err := os.ReadFile(path) if err != nil { return err } if !bytes.Equal(got, want) { return fmt.Errorf("%s does not match %s byte-for-byte", path, reference) } } return nil } func copyExecutable(source, destination string) error { input, err := os.Open(source) if err != nil { return err } defer input.Close() output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0755) if err != nil { return err } removeOnError := true defer func() { _ = output.Close() if removeOnError { _ = os.Remove(destination) } }() if _, err := io.Copy(output, input); err != nil { return err } if err := output.Close(); err != nil { return err } removeOnError = false return nil } func executableName(base string) string { if runtime.GOOS == "windows" { return base + ".exe" } return base } func fatal(err error) { fmt.Fprintln(os.Stderr, "qualify-selfupdate:", err) os.Exit(1) }