package installer import ( "context" "debug/pe" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "runtime" "sort" "strings" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" ) const ( windowsOwnershipManifestFile = ".mpv-manager-owned.json" windowsOwnershipManifestProduct = "mpv.rocks/mpv-install" windowsOwnershipManifestVersion = 1 windowsPreservedCollisionPrefix = ".mpv-manager-preserved-" ) type windowsOwnershipManifest struct { Version int `json:"version"` Product string `json:"product"` InstallDir string `json:"install_dir"` Files []string `json:"files"` } // ErrWindowsUninstallBlocked means an owned file is open or inaccessible. The // preflight returns this before removing payload files, shortcuts or registration. var ErrWindowsUninstallBlocked = errors.New("Close mpv and any apps using its installation folder, then retry uninstalling. If the problem persists, check the folder's permissions") // Existing manifests are authoritative. Legacy managed records authorize only // the recognizable MPV launchers, never an inventory of the whole directory. func previousWindowsOwnership(installDir string) (*windowsOwnershipManifest, error) { if _, err := os.Lstat(filepath.Join(installDir, windowsOwnershipManifestFile)); err == nil { return loadWindowsOwnershipManifest(installDir) } else if !os.IsNotExist(err) { return nil, err } entries, err := os.ReadDir(installDir) if os.IsNotExist(err) { return nil, nil } if err != nil { return nil, err } populated := false for _, entry := range entries { if !containsFold(constants.WindowsPreservePaths, entry.Name()) { populated = true break } } if !populated { return nil, nil } root, err := canonicalOwnershipRoot(installDir) if err != nil { return nil, err } for _, app := range config.GetInstalledApps() { if !app.Managed || app.InstallPath == "" || (app.InstallMethod != constants.MethodMPVBinary && app.InstallMethod != constants.MethodMPVBinaryV3) { continue } tracked, err := canonicalOwnershipRoot(app.InstallPath) if err == nil && sameOwnershipPath(root, tracked) { return legacyWindowsOwnership(root) } } return nil, fmt.Errorf("refusing to modify populated directory without %s ownership proof: %s; adopt the selected MPV installation first", windowsOwnershipManifestFile, installDir) } func validateWindowsInstallOwnership(installDir string) error { _, err := previousWindowsOwnership(installDir) return err } func legacyWindowsOwnership(installDir string) (*windowsOwnershipManifest, error) { root, err := canonicalOwnershipRoot(installDir) if err != nil { return nil, err } files := []string{windowsOwnershipManifestFile} for _, name := range []string{"mpv.exe", "mpv.com"} { path := filepath.Join(root, name) if name == "mpv.com" { if _, err := os.Lstat(path); os.IsNotExist(err) { continue } } if err := validateNonEmptyRegularFile(path, false); err != nil { return nil, fmt.Errorf("verify legacy MPV launcher %s: %w", name, err) } executable, err := pe.Open(path) if err != nil { return nil, fmt.Errorf("verify legacy MPV launcher %s: %w", name, err) } machine := executable.FileHeader.Machine executable.Close() if machine != pe.IMAGE_FILE_MACHINE_AMD64 && machine != pe.IMAGE_FILE_MACHINE_ARM64 { return nil, fmt.Errorf("unsupported legacy MPV launcher architecture %#x", machine) } files = append(files, name) } return &windowsOwnershipManifest{Version: windowsOwnershipManifestVersion, Product: windowsOwnershipManifestProduct, InstallDir: root, Files: files}, nil } // MigrateWindowsInstallOwnership records the two MPV launchers selected by an // explicit adoption. DLLs, scripts, documents and user files remain unclaimed. func MigrateWindowsInstallOwnership(installDir string) error { return fileops.WithLock(filepath.Join(installDir, windowsOwnershipManifestFile), func() error { if _, err := os.Lstat(filepath.Join(installDir, windowsOwnershipManifestFile)); err == nil { _, err = loadWindowsOwnershipManifest(installDir) return err } else if !os.IsNotExist(err) { return err } manifest, err := legacyWindowsOwnership(installDir) if err != nil { return err } data, err := json.MarshalIndent(manifest, "", " ") if err != nil { return err } return fileops.AtomicWrite(filepath.Join(installDir, windowsOwnershipManifestFile), append(data, '\n'), constants.FilePermission) }) } // prepareWindowsOwnershipManifest writes a manifest into the staged payload. // transactionalOverlay then commits or rolls it back with the executable // files, so a completed install can never publish payload bytes without the // ownership record required for safe uninstall. func prepareWindowsOwnershipManifest(ctx context.Context, payloadDir, installDir string) error { entries, err := collectOverlayEntries(ctx, payloadDir, constants.WindowsPreservePaths) if err != nil { return fmt.Errorf("collect owned Windows payload: %w", err) } owned := make(map[string]struct{}) for _, entry := range entries { if entry.isDir { continue } rel, err := validateOwnedRelativePath(filepath.ToSlash(entry.rel)) if err != nil { return err } owned[rel] = struct{}{} } previous, err := previousWindowsOwnership(installDir) if err != nil { return err } if previous != nil { for _, rel := range previous.Files { owned[rel] = struct{}{} } } owned[windowsOwnershipManifestFile] = struct{}{} files := make([]string, 0, len(owned)) for rel := range owned { files = append(files, rel) } sort.Strings(files) root, err := canonicalOwnershipRoot(installDir) if err != nil { return err } manifest := windowsOwnershipManifest{ Version: windowsOwnershipManifestVersion, Product: windowsOwnershipManifestProduct, InstallDir: root, Files: files, } data, err := json.MarshalIndent(manifest, "", " ") if err != nil { return fmt.Errorf("encode Windows ownership manifest: %w", err) } data = append(data, '\n') if err := fileops.AtomicWrite(filepath.Join(payloadDir, windowsOwnershipManifestFile), data, constants.FilePermission); err != nil { return fmt.Errorf("stage Windows ownership manifest: %w", err) } return nil } func loadWindowsOwnershipManifest(installDir string) (*windowsOwnershipManifest, error) { path := filepath.Join(installDir, windowsOwnershipManifestFile) info, err := os.Lstat(path) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("refusing Windows uninstall without ownership manifest %s", path) } return nil, fmt.Errorf("inspect Windows ownership manifest: %w", err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return nil, fmt.Errorf("Windows ownership manifest is not a regular file: %s", path) } file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() data, err := io.ReadAll(io.LimitReader(file, (1<<20)+1)) if len(data) > 1<<20 { return nil, fmt.Errorf("Windows ownership manifest exceeds 1 MiB") } if err != nil { return nil, fmt.Errorf("read Windows ownership manifest: %w", err) } var manifest windowsOwnershipManifest decoder := json.NewDecoder(strings.NewReader(string(data))) decoder.DisallowUnknownFields() if err := decoder.Decode(&manifest); err != nil { return nil, fmt.Errorf("decode Windows ownership manifest: %w", err) } if err := decoder.Decode(new(any)); err != io.EOF { return nil, fmt.Errorf("Windows ownership manifest contains trailing data") } if manifest.Version != windowsOwnershipManifestVersion || manifest.Product != windowsOwnershipManifestProduct { return nil, fmt.Errorf("unsupported Windows ownership manifest identity") } root, err := canonicalOwnershipRoot(installDir) if err != nil { return nil, err } if !sameOwnershipPath(root, manifest.InstallDir) { return nil, fmt.Errorf("Windows ownership manifest belongs to %q, not %q", manifest.InstallDir, root) } seen := make(map[string]struct{}, len(manifest.Files)) validated := make([]string, 0, len(manifest.Files)) for _, rel := range manifest.Files { clean, err := validateOwnedRelativePath(rel) if err != nil { return nil, err } if _, duplicate := seen[clean]; duplicate { continue } seen[clean] = struct{}{} validated = append(validated, clean) } if _, ok := seen[windowsOwnershipManifestFile]; !ok { return nil, fmt.Errorf("Windows ownership manifest does not own itself") } manifest.Files = validated return &manifest, nil } func validateOwnedRelativePath(rel string) (string, error) { rel = filepath.FromSlash(strings.TrimSpace(rel)) clean := filepath.Clean(rel) if clean == "." || !filepath.IsLocal(clean) || filepath.IsAbs(clean) { return "", fmt.Errorf("unsafe path in Windows ownership manifest: %q", rel) } if clean != windowsOwnershipManifestFile { top := clean if separator := strings.IndexRune(clean, filepath.Separator); separator >= 0 { top = clean[:separator] } if containsFold(constants.WindowsPreservePaths, top) || strings.HasPrefix(strings.ToLower(top), windowsPreservedCollisionPrefix) { return "", fmt.Errorf("ownership manifest may not claim preserved path %q", clean) } } return filepath.ToSlash(clean), nil } func uninstallOwnedWindowsPayload(installDir string) error { manifest, err := previousWindowsOwnership(installDir) if err != nil { return err } if manifest == nil { return fmt.Errorf("Windows uninstall has no owned payload") } files := append([]string(nil), manifest.Files...) sort.Slice(files, func(i, j int) bool { return strings.Count(files[i], "/") > strings.Count(files[j], "/") }) directories := make(map[string]struct{}) var removalErrors []error for _, slashRel := range files { if slashRel == windowsOwnershipManifestFile { continue } rel := filepath.FromSlash(slashRel) path, err := safeOwnedPath(installDir, rel) if err != nil { removalErrors = append(removalErrors, err) continue } if err := os.Remove(path); err != nil && !os.IsNotExist(err) { removalErrors = append(removalErrors, fmt.Errorf("remove owned file %s: %w", slashRel, err)) continue } for dir := filepath.Dir(rel); dir != "."; dir = filepath.Dir(dir) { directories[dir] = struct{}{} } } dirs := make([]string, 0, len(directories)) for dir := range directories { dirs = append(dirs, dir) } sort.Slice(dirs, func(i, j int) bool { return strings.Count(dirs[i], string(filepath.Separator)) > strings.Count(dirs[j], string(filepath.Separator)) }) for _, rel := range dirs { path, err := safeOwnedPath(installDir, rel) if err != nil { removalErrors = append(removalErrors, err) continue } entries, err := os.ReadDir(path) switch { case os.IsNotExist(err): continue case err != nil: removalErrors = append(removalErrors, fmt.Errorf("inspect owned directory %s: %w", rel, err)) continue case len(entries) != 0: continue // user-added content keeps the directory alive } if err := os.Remove(path); err != nil && !os.IsNotExist(err) { removalErrors = append(removalErrors, fmt.Errorf("remove empty owned directory %s: %w", rel, err)) } } if len(removalErrors) != 0 { return errors.Join(removalErrors...) } if err := os.Remove(filepath.Join(installDir, windowsOwnershipManifestFile)); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove Windows ownership manifest: %w", err) } return nil } // safeOwnedPath rejects intermediate links/reparse-like entries before any // deletion. Removing a final symlink is safe; traversing one is not. func safeOwnedPath(root, rel string) (string, error) { clean, err := validateOwnedRelativePath(filepath.ToSlash(rel)) if err != nil { return "", err } root, err = canonicalOwnershipRoot(root) if err != nil { return "", err } rel = filepath.FromSlash(clean) path := filepath.Join(root, rel) within, err := filepath.Rel(root, path) if err != nil || !filepath.IsLocal(within) { return "", fmt.Errorf("owned path escapes install directory: %q", rel) } parent := filepath.Dir(rel) current := root if parent != "." { for _, component := range strings.Split(parent, string(filepath.Separator)) { current = filepath.Join(current, component) info, statErr := os.Lstat(current) if os.IsNotExist(statErr) { break } if statErr != nil { return "", fmt.Errorf("inspect owned path parent %s: %w", current, statErr) } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return "", fmt.Errorf("refusing to traverse non-directory or linked owned path parent: %s", current) } } } return path, nil } func canonicalOwnershipRoot(path string) (string, error) { abs, err := filepath.Abs(filepath.Clean(path)) if err != nil { return "", fmt.Errorf("resolve Windows install directory: %w", err) } return abs, nil } func sameOwnershipPath(left, right string) bool { left = filepath.Clean(left) right = filepath.Clean(right) if runtime.GOOS == constants.OSWindows { return strings.EqualFold(left, right) } return left == right } func containsFold(values []string, candidate string) bool { for _, value := range values { if strings.EqualFold(value, candidate) { return true } } return false }