From 91d4989531be3f7c3d8d9760234e6134b5c15711 Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Sat, 2 Aug 2025 12:51:53 +0700 Subject: [PATCH] initial commit --- .gitignore | 1 + cmd/create.go | 279 +++++++++++++++++++++++++++++++++++++++++++++++++ cmd/restore.go | 273 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 19 ++++ go.mod | 34 ++++++ main.go | 7 ++ 6 files changed, 613 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/create.go create mode 100644 cmd/restore.go create mode 100644 cmd/root.go create mode 100644 go.mod create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08cb523 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +go.sum diff --git a/cmd/create.go b/cmd/create.go new file mode 100644 index 0000000..f39024b --- /dev/null +++ b/cmd/create.go @@ -0,0 +1,279 @@ +package cmd + +import ( + "compress/gzip" + "fmt" + "io" + "os" + "runtime" + "syscall" + "time" + "unsafe" + + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + "golang.org/x/sys/unix" +) + +var ( + source string + target string +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a new backup", + Long: "Create a new backup of a file or disk", + Run: func(cmd *cobra.Command, args []string) { + model, err := initialModel(source, target+".gz") + if err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } + + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } + }, +} + +type model struct { + progress progress.Model + source string + target string + totalBytes int64 + processed int64 + done bool + err error + startTime time.Time + progressCh chan int64 +} + +type progressMsg int64 +type doneMsg struct{} +type errorMsg struct{ err error } + +// getFileSize возвращает размер файла или диска +func getFileSize(path string) (int64, error) { + fileInfo, err := os.Stat(path) + if err != nil { + return 0, err + } + + // Для Linux: проверка, является ли источник блочным устройством + if runtime.GOOS == "linux" { + stat, ok := fileInfo.Sys().(*syscall.Stat_t) + if ok && (stat.Mode&syscall.S_IFMT) == syscall.S_IFBLK { + // Это блочное устройство - получаем размер через ioctl + f, err := os.Open(path) + if err != nil { + return 0, err + } + defer f.Close() + + var size uint64 + _, _, errno := unix.Syscall( + unix.SYS_IOCTL, + f.Fd(), + unix.BLKGETSIZE64, + uintptr(unsafe.Pointer(&size)), + ) + if errno != 0 { + return 0, fmt.Errorf("ioctl BLKGETSIZE64 failed: %v", errno) + } + return int64(size), nil + } + } + + // Для обычных файлов и других систем + return fileInfo.Size(), nil +} + +func initialModel(src, dst string) (*model, error) { + totalBytes, err := getFileSize(src) + if err != nil { + return nil, err + } + + p := progress.New( + progress.WithDefaultGradient(), + progress.WithWidth(40), + ) + + return &model{ + progress: p, + source: src, + target: dst, + totalBytes: totalBytes, + startTime: time.Now(), + progressCh: make(chan int64, 100), + }, nil +} + +func (m *model) Init() tea.Cmd { + return tea.Batch( + m.startCompression, + m.progressListener, + ) +} + +func (m *model) startCompression() tea.Msg { + // Переносим всю работу с файлами внутрь горутины + go func() { + srcFile, err := os.Open(m.source) + if err != nil { + m.progressCh <- -1 + return + } + defer srcFile.Close() + + dstFile, err := os.Create(m.target) + if err != nil { + m.progressCh <- -1 + return + } + defer dstFile.Close() + + gzipWriter := gzip.NewWriter(dstFile) + defer gzipWriter.Close() + + // Для блочных устройств используем прямой доступ + buf := make([]byte, 32*1024) // 32KB buffer + for { + n, err := srcFile.Read(buf) + if n > 0 { + _, writeErr := gzipWriter.Write(buf[:n]) + if writeErr != nil { + m.progressCh <- -1 + return + } + m.progressCh <- int64(n) + } + + if err == io.EOF { + break + } + + if err != nil { + m.progressCh <- -1 + return + } + } + + m.progressCh <- -2 // Сигнал завершения + }() + + return nil +} + +func (m *model) progressListener() tea.Msg { + select { + case n := <-m.progressCh: + switch { + case n == -1: // Ошибка + return errorMsg{fmt.Errorf("compression error")} + case n == -2: // Завершение + return doneMsg{} + default: // Прогресс + m.processed += n + return progressMsg(m.processed) + } + } +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + return m, nil + + case tea.WindowSizeMsg: + m.progress.Width = msg.Width - 4 + return m, nil + + case progressMsg: + // Обновляем прогрессбар + progressVal := float64(msg) / float64(m.totalBytes) + if progressVal > 1.0 { + progressVal = 1.0 + } + + // Создаем команду для обновления прогрессбара + cmd := m.progress.SetPercent(progressVal) + + // Продолжаем слушать обновления + return m, tea.Batch(cmd, m.progressListener) + + case doneMsg: + m.done = true + // Устанавливаем 100% и завершаем + cmd := m.progress.SetPercent(1.0) + return m, tea.Batch(cmd, tea.Quit) + + case errorMsg: + m.err = msg.err + return m, tea.Quit + } + + return m, nil +} + +func (m *model) View() string { + if m.err != nil { + return fmt.Sprintf("\n❌ Error: %v\n\n", m.err) + } + + if m.done { + duration := time.Since(m.startTime) + ratio := float64(m.processed) / float64(m.totalBytes) * 100 + return fmt.Sprintf("\n✅ Backup complete!\n\n"+ + "Source: %s\n"+ + "Backup: %s\n"+ + "Size: %s → %s (%.1f%%)\n"+ + "Time: %v\n\n", + m.source, m.target, + formatBytes(m.totalBytes), formatBytes(m.processed), ratio, + duration.Round(time.Millisecond)) + } + + title := fmt.Sprintf("Backing up %s → %s", m.source, m.target) + progressVal := float64(m.processed) / float64(m.totalBytes) + progressView := m.progress.ViewAs(progressVal) + stats := fmt.Sprintf("%s / %s (%.1f%%)", formatBytes(m.processed), formatBytes(m.totalBytes), progressVal*100) + + return lipgloss.JoinVertical(lipgloss.Left, + title, + "", + progressView, + "", + stats, + "\nPress Ctrl+C to cancel", + ) +} + +func formatBytes(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) +} + +func init() { + rootCmd.AddCommand(createCmd) + createCmd.Flags().StringVarP(&source, "source", "s", "", "Source file or disk (e.g., /dev/sda) (required)") + createCmd.Flags().StringVarP(&target, "target", "t", "", "Target backup file (required)") + createCmd.MarkFlagRequired("source") + createCmd.MarkFlagRequired("target") +} diff --git a/cmd/restore.go b/cmd/restore.go new file mode 100644 index 0000000..7a5a5ef --- /dev/null +++ b/cmd/restore.go @@ -0,0 +1,273 @@ +package cmd + +import ( + "compress/gzip" + "fmt" + "io" + "os" + "runtime" + "syscall" + "time" + + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" +) + +var ( + restoreSource string + restoreTarget string +) + +var restoreCmd = &cobra.Command{ + Use: "restore", + Short: "Restore a backup", + Long: "Restore a backup from a gzip archive to a file or disk", + Run: func(cmd *cobra.Command, args []string) { + model, err := initialRestoreModel(restoreSource, restoreTarget) + if err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } + + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } + }, +} + +type restoreModel struct { + progress progress.Model + source string + target string + totalBytes int64 + processed int64 + done bool + err error + startTime time.Time + progressCh chan int64 +} + +type restoreProgressMsg int64 +type restoreDoneMsg struct{} +type restoreErrorMsg struct{ err error } + +func initialRestoreModel(src, dst string) (*restoreModel, error) { + fileInfo, err := os.Stat(src) + if err != nil { + return nil, err + } + + p := progress.New( + progress.WithDefaultGradient(), + progress.WithWidth(40), + ) + + return &restoreModel{ + progress: p, + source: src, + target: dst, + totalBytes: fileInfo.Size(), + startTime: time.Now(), + progressCh: make(chan int64, 100), + }, nil +} + +func (m *restoreModel) Init() tea.Cmd { + return tea.Batch( + m.startRestore, + m.restoreProgressListener, + ) +} + +func (m *restoreModel) startRestore() tea.Msg { + go func() { + srcFile, err := os.Open(m.source) + if err != nil { + m.progressCh <- -1 + return + } + defer srcFile.Close() + + // Создаем кастомный reader для отслеживания прогресса + progressReader := &restoreProgressReader{ + reader: srcFile, + total: m.totalBytes, + progressCh: m.progressCh, + } + + // Создаем gzip reader на основе кастомного reader'а + gzipReader, err := gzip.NewReader(progressReader) + if err != nil { + m.progressCh <- -1 + return + } + defer gzipReader.Close() + + // Определяем, является ли цель блочным устройством + var dstFile *os.File + if isBlockDevice(m.target) { + // Открываем блочное устройство для записи (без усечения!) + dstFile, err = os.OpenFile(m.target, os.O_WRONLY|os.O_SYNC, 0) + if err != nil { + m.progressCh <- -1 + return + } + } else { + // Обычный файл: создаем/усекаем + dstFile, err = os.Create(m.target) + if err != nil { + m.progressCh <- -1 + return + } + } + defer dstFile.Close() + + // Копируем распакованные данные в целевой файл/устройство + _, err = io.Copy(dstFile, gzipReader) + if err != nil { + m.progressCh <- -1 + return + } + + // Синхронизируем, чтобы все данные точно записались + if err := dstFile.Sync(); err != nil { + m.progressCh <- -1 + return + } + + m.progressCh <- -2 // Сигнал завершения + }() + + return nil +} + +// restoreProgressReader отслеживает прогресс чтения архива +type restoreProgressReader struct { + reader io.Reader + total int64 + progressCh chan int64 + read int64 +} + +func (r *restoreProgressReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + if n > 0 { + r.read += int64(n) + r.progressCh <- r.read + } + return n, err +} + +func (m *restoreModel) restoreProgressListener() tea.Msg { + select { + case n := <-m.progressCh: + switch { + case n == -1: // Ошибка + return restoreErrorMsg{fmt.Errorf("restoration error")} + case n == -2: // Завершение + return restoreDoneMsg{} + default: // Прогресс + m.processed = n + return restoreProgressMsg(n) + } + } +} + +func (m *restoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + return m, nil + + case tea.WindowSizeMsg: + m.progress.Width = msg.Width - 4 + return m, nil + + case restoreProgressMsg: + // Обновляем прогрессбар + progressVal := float64(msg) / float64(m.totalBytes) + if progressVal > 1.0 { + progressVal = 1.0 + } + + // Создаем команду для обновления прогрессбара + cmd := m.progress.SetPercent(progressVal) + + // Продолжаем слушать обновления + return m, tea.Batch(cmd, m.restoreProgressListener) + + case restoreDoneMsg: + m.done = true + // Устанавливаем 100% и завершаем + cmd := m.progress.SetPercent(1.0) + return m, tea.Batch(cmd, tea.Quit) + + case restoreErrorMsg: + m.err = msg.err + return m, tea.Quit + } + + return m, nil +} + +func (m *restoreModel) View() string { + if m.err != nil { + return fmt.Sprintf("\n❌ Error: %v\n\n", m.err) + } + + if m.done { + duration := time.Since(m.startTime) + return fmt.Sprintf("\n✅ Restoration complete!\n\n"+ + "File: %s → %s\n"+ + "Time: %v\n\n", + m.source, m.target, + duration.Round(time.Millisecond)) + } + + title := fmt.Sprintf("Restoring %s → %s", m.source, m.target) + progressVal := float64(m.processed) / float64(m.totalBytes) + progressView := m.progress.ViewAs(progressVal) + stats := fmt.Sprintf("%s / %s (%.1f%%)", formatBytes(m.processed), formatBytes(m.totalBytes), progressVal*100) + + return lipgloss.JoinVertical(lipgloss.Left, + title, + "", + progressView, + "", + stats, + "\nPress Ctrl+C to cancel", + ) +} + +// isBlockDevice проверяет, является ли путь блочным устройством (только для Linux) +func isBlockDevice(path string) bool { + if runtime.GOOS != "linux" { + return false + } + + fileInfo, err := os.Stat(path) + if err != nil { + return false + } + + stat, ok := fileInfo.Sys().(*syscall.Stat_t) + if !ok { + return false + } + + return (stat.Mode & syscall.S_IFMT) == syscall.S_IFBLK +} + +func init() { + rootCmd.AddCommand(restoreCmd) + restoreCmd.Flags().StringVarP(&restoreSource, "source", "s", "", "Source archive (required)") + restoreCmd.Flags().StringVarP(&restoreTarget, "target", "t", "", "Target file or disk (e.g., /dev/sdb) (required)") + restoreCmd.MarkFlagRequired("source") + restoreCmd.MarkFlagRequired("target") +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..aa4817a --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "backup", + Short: "Backup your data", + Long: "Backup your data locally or remotely", +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9e115ba --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module backup + +go 1.24.5 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.4 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/spf13/cobra v1.9.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..a3c94ef --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "backup/cmd" + +func main() { + cmd.Execute() +}