package main import ( "archive/zip" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" tea "github.com/charmbracelet/bubbletea" ) func executeAction(versionDir string, action Action, index int, backupDir string) tea.Cmd { total := index + 1 prefix := fmt.Sprintf("[%d]", total) switch action.Type { case "add": return executeAdd(versionDir, action, index, backupDir, prefix) case "delete": return func() tea.Msg { absPath, _, err := safeJoin(versionDir, action.Path) if err != nil { return actionErrorMsg{index: index, err: err} } if err := backupPath(versionDir, action.Path, backupDir); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("backup failed: %w", err)} } if err := os.RemoveAll(absPath); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("delete failed: %w", err)} } return actionCompleteMsg{index: index} } case "copy": return func() tea.Msg { dst, _, err := safeJoin(versionDir, action.NewPath) if err != nil { return actionErrorMsg{index: index, err: err} } src, _, err := safeJoin(versionDir, action.Path) if err != nil { return actionErrorMsg{index: index, err: err} } if _, err := validateCopyPath(src, dst); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("copy failed: %w", err)} } if _, err := os.Stat(dst); err == nil { if err := backupPath(versionDir, action.NewPath, backupDir); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("backup failed: %w", err)} } } if err := copyPath(src, dst); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("copy failed: %w", err)} } return actionCompleteMsg{index: index} } case "move": return func() tea.Msg { src, _, err := safeJoin(versionDir, action.Path) if err != nil { return actionErrorMsg{index: index, err: err} } dst, _, err := safeJoin(versionDir, action.NewPath) if err != nil { return actionErrorMsg{index: index, err: err} } if _, err := os.Stat(dst); err == nil { if err := backupPath(versionDir, action.NewPath, backupDir); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("backup failed: %w", err)} } } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("mkdir failed: %w", err)} } if err := os.Rename(src, dst); err != nil { if err := copyPath(src, dst); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("move failed: %w", err)} } if err := os.RemoveAll(src); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("cleanup failed: %w", err)} } } return actionCompleteMsg{index: index} } case "new": return func() tea.Msg { absPath, _, err := safeJoin(versionDir, action.Path) if err != nil { return actionErrorMsg{index: index, err: err} } if _, err := os.Stat(absPath); err == nil { return actionCompleteMsg{index: index} } if action.IsDir { if err := os.MkdirAll(absPath, 0o755); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("create dir failed: %w", err)} } } else { if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("mkdir failed: %w", err)} } f, err := os.Create(absPath) if err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("create file failed: %w", err)} } f.Close() } return actionCompleteMsg{index: index} } default: return func() tea.Msg { return actionErrorMsg{index: index, err: fmt.Errorf("unknown action type: %s", action.Type)} } } } func executeAdd(versionDir string, action Action, index int, backupDir, prefix string) tea.Cmd { return func() tea.Msg { absPath, _, err := safeJoin(versionDir, action.Path) if err != nil { return actionErrorMsg{index: index, err: err} } if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("mkdir failed: %w", err)} } if _, err := os.Stat(absPath); err == nil { if err := backupPath(versionDir, action.Path, backupDir); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("backup failed: %w", err)} } } err = downloadFile(action.URL, absPath) if err != nil { if len(action.Mirrors) > 0 { return mirrorChoiceMsg{index: index, mirrors: action.Mirrors, action: action} } return actionErrorMsg{index: index, err: fmt.Errorf("download failed: %w", err)} } if action.Unzip { destDir := filepath.Dir(absPath) if err := unzipFile(absPath, destDir, versionDir, backupDir); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("unzip failed: %w", err)} } if err := os.Remove(absPath); err != nil { return actionErrorMsg{index: index, err: fmt.Errorf("cleanup failed: %w", err)} } } return actionCompleteMsg{index: index} } } func waitForProgress(ch chan float64, index int) tea.Cmd { return func() tea.Msg { p, ok := <-ch if !ok { return nil } return actionProgressMsg{percent: p} } } func downloadFile(url, destPath string) error { resp, err := http.Get(url) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d", resp.StatusCode) } tmpPath := destPath + ".tmp" f, err := os.Create(tmpPath) if err != nil { return err } _, err = io.Copy(f, resp.Body) f.Close() if err != nil { os.Remove(tmpPath) return err } if err := os.Rename(tmpPath, destPath); err != nil { os.Remove(tmpPath) return err } return nil } func unzipFile(zipPath, destDir, versionDir, backupDir string) error { r, err := zip.OpenReader(zipPath) if err != nil { return err } defer r.Close() versionAbs, err := filepath.Abs(versionDir) if err != nil { return err } destAbs, err := filepath.Abs(destDir) if err != nil { return err } for _, f := range r.File { if f.FileInfo().Mode()&os.ModeSymlink != 0 { return fmt.Errorf("refusing to extract symlink: %s", f.Name) } target, err := safeZipTarget(destAbs, f.Name) if err != nil { return err } if f.FileInfo().IsDir() { if err := os.MkdirAll(target, 0o755); err != nil { return err } continue } if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } rel, err := filepath.Rel(versionAbs, target) if err != nil { return err } if rel == "." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { return fmt.Errorf("zip target escapes version directory: %s", f.Name) } if isBackupRelativePath(rel) { return fmt.Errorf("refusing to extract into backup directory: %s", f.Name) } if _, err := os.Stat(target); err == nil { if err := backupPath(versionDir, rel, backupDir); err != nil { return err } } else if !os.IsNotExist(err) { return err } rc, err := f.Open() if err != nil { return err } outFile, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.FileInfo().Mode().Perm()) if err != nil { rc.Close() return err } _, err = io.Copy(outFile, rc) outFile.Close() rc.Close() if err != nil { return err } } return nil } func safeJoin(versionDir, relativePath string) (string, string, error) { if strings.TrimSpace(relativePath) == "" { return "", "", fmt.Errorf("empty path is not allowed") } if filepath.IsAbs(relativePath) || filepath.VolumeName(relativePath) != "" || strings.HasPrefix(relativePath, "/") || strings.HasPrefix(relativePath, "\\") { return "", "", fmt.Errorf("absolute path is not allowed: %s", relativePath) } cleanRel := filepath.Clean(relativePath) if cleanRel == "." || cleanRel == ".." || strings.HasPrefix(cleanRel, ".."+string(os.PathSeparator)) { return "", "", fmt.Errorf("path escapes version directory: %s", relativePath) } if isBackupRelativePath(cleanRel) { return "", "", fmt.Errorf("refusing to operate on backup directory: %s", relativePath) } base, err := filepath.Abs(versionDir) if err != nil { return "", "", err } target, err := filepath.Abs(filepath.Join(base, cleanRel)) if err != nil { return "", "", err } if !pathInside(base, target) { return "", "", fmt.Errorf("path escapes version directory: %s", relativePath) } return target, cleanRel, nil } func safeZipTarget(destDir, zipName string) (string, error) { if strings.TrimSpace(zipName) == "" { return "", fmt.Errorf("empty zip entry name") } if filepath.IsAbs(zipName) || filepath.VolumeName(zipName) != "" || strings.HasPrefix(zipName, "/") || strings.HasPrefix(zipName, "\\") { return "", fmt.Errorf("absolute zip entry is not allowed: %s", zipName) } cleanName := filepath.Clean(zipName) if cleanName == "." || cleanName == ".." || strings.HasPrefix(cleanName, ".."+string(os.PathSeparator)) { return "", fmt.Errorf("zip entry escapes target directory: %s", zipName) } target, err := filepath.Abs(filepath.Join(destDir, cleanName)) if err != nil { return "", err } if !pathInside(destDir, target) { return "", fmt.Errorf("zip entry escapes target directory: %s", zipName) } return target, nil } func isBackupRelativePath(relativePath string) bool { clean := filepath.ToSlash(filepath.Clean(relativePath)) return clean == "amt/backup" || strings.HasPrefix(clean, "amt/backup/") } func pathInside(base, target string) bool { rel, err := filepath.Rel(base, target) if err != nil { return false } return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel)) } func newBackupDir() string { return fmt.Sprintf("amt/backup/%s", time.Now().Format("20060102_150405_000000000")) } func backupPath(versionDir, relativePath, backupDir string) error { src, cleanRel, err := safeJoin(versionDir, relativePath) if err != nil { return err } info, err := os.Lstat(src) if os.IsNotExist(err) { return nil } if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("refusing to back up symlink: %s", relativePath) } backupRoot, err := backupRootPath(versionDir, backupDir) if err != nil { return err } dst := filepath.Join(backupRoot, cleanRel) if info.IsDir() && pathInside(src, dst) { return fmt.Errorf("refusing to back up %s into itself", relativePath) } dst, err = uniqueBackupDestination(dst) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } if info.IsDir() { return copyDir(src, dst) } return copyFile(src, dst) } func backupRootPath(versionDir, backupDir string) (string, error) { if strings.TrimSpace(backupDir) == "" { return "", fmt.Errorf("empty backup directory") } if filepath.IsAbs(backupDir) { return "", fmt.Errorf("absolute backup directory is not allowed: %s", backupDir) } cleanRel := filepath.Clean(backupDir) cleanSlash := filepath.ToSlash(cleanRel) if cleanSlash != "amt/backup" && !strings.HasPrefix(cleanSlash, "amt/backup/") { return "", fmt.Errorf("backup directory must be under amt/backup: %s", backupDir) } base, err := filepath.Abs(versionDir) if err != nil { return "", err } root, err := filepath.Abs(filepath.Join(base, cleanRel)) if err != nil { return "", err } if !pathInside(base, root) { return "", fmt.Errorf("backup directory escapes version directory: %s", backupDir) } return root, nil } func uniqueBackupDestination(dst string) (string, error) { if _, err := os.Lstat(dst); os.IsNotExist(err) { return dst, nil } else if err != nil { return "", err } for i := 1; ; i++ { candidate := fmt.Sprintf("%s.%d", dst, i) if _, err := os.Lstat(candidate); os.IsNotExist(err) { return candidate, nil } else if err != nil { return "", err } } } func validateCopyPath(src, dst string) (os.FileInfo, error) { info, err := os.Lstat(src) if err != nil { return nil, err } if info.Mode()&os.ModeSymlink != 0 { return nil, fmt.Errorf("refusing to copy symlink: %s", src) } srcAbs, err := filepath.Abs(src) if err != nil { return nil, err } dstAbs, err := filepath.Abs(dst) if err != nil { return nil, err } if srcAbs == dstAbs { return nil, fmt.Errorf("source and destination are the same: %s", src) } if info.IsDir() && pathInside(srcAbs, dstAbs) { return nil, fmt.Errorf("refusing to copy directory into itself: %s", src) } return info, nil } func copyPath(src, dst string) error { info, err := validateCopyPath(src, dst) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } if info.IsDir() { return copyDir(src, dst) } return copyFile(src, dst) } func copyFile(src, dst string) error { info, err := os.Lstat(src) if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("refusing to copy symlink: %s", src) } in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm()) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() return err } if err := out.Close(); err != nil { return err } if err := os.Chmod(dst, info.Mode().Perm()); err != nil { return err } return os.Chtimes(dst, info.ModTime(), info.ModTime()) } func copyDir(src, dst string) error { rootInfo, err := os.Lstat(src) if err != nil { return err } if rootInfo.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("refusing to copy symlink: %s", src) } return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("refusing to copy symlink: %s", path) } rel, err := filepath.Rel(src, path) if err != nil { return err } target := filepath.Join(dst, rel) if info.IsDir() { if err := os.MkdirAll(target, info.Mode().Perm()); err != nil { return err } return os.Chtimes(target, info.ModTime(), info.ModTime()) } return copyFile(path, target) }) }