diff --git a/cmd/create.go b/cmd/create.go index e42b70c..e5a571d 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -3,8 +3,10 @@ package cmd import ( "archive/tar" "compress/gzip" + "encoding/binary" "fmt" "io" + "net" "os" "path/filepath" "runtime" @@ -44,23 +46,24 @@ var createCmd = &cobra.Command{ } type model struct { - progress progress.Model - source string - target string - totalBytes int64 - processed int64 - done bool - err error - startTime time.Time - progressCh chan int64 - isDirectory bool + progress progress.Model + source string + target string + totalBytes int64 + processed int64 + done bool + err error + startTime time.Time + progressCh chan int64 + isDirectory bool + isNetworkTarget bool } type progressMsg int64 type doneMsg struct{} type errorMsg struct{ err error } -// getFileSize возвращает размер файла, диска или директории +// getPathSize возвращает размер файла, диска или директории func getPathSize(path string) (int64, error) { fileInfo, err := os.Stat(path) if err != nil { @@ -121,26 +124,35 @@ func initialModel(src, dst string) (*model, error) { progress.WithWidth(40), ) - // Проверяем, является ли источник директорией srcInfo, err := os.Stat(src) if err != nil { return nil, err } - - // Добавляем расширение .tar.gz для директорий + isDirectory := srcInfo.IsDir() + networkTarget := isNetworkAddress(dst) targetPath := dst - if srcInfo.IsDir() && filepath.Ext(targetPath) != ".tar.gz" { - targetPath += ".tar.gz" + + if !networkTarget { + if isDirectory { + if filepath.Ext(targetPath) != ".tar.gz" { + targetPath += ".tar.gz" + } + } else { // Это файл или диск + if filepath.Ext(targetPath) != ".gz" { + targetPath += ".gz" + } + } } return &model{ - progress: p, - source: src, - target: targetPath, - totalBytes: totalBytes, - startTime: time.Now(), - progressCh: make(chan int64, 100), - isDirectory: srcInfo.IsDir(), + progress: p, + source: src, + target: targetPath, + totalBytes: totalBytes, + startTime: time.Now(), + progressCh: make(chan int64, 100), + isDirectory: isDirectory, + isNetworkTarget: networkTarget, }, nil } @@ -151,57 +163,58 @@ func (m *model) Init() tea.Cmd { ) } +// getTargetWriter создает io.WriteCloser для файла или сетевого подключения +func (m *model) getTargetWriter() (io.WriteCloser, error) { + if m.isNetworkTarget { + conn, err := net.DialTimeout("tcp", m.target, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("failed to connect to %s: %w", m.target, err) + } + + // Протокол: сначала отправляем 8 байт (размер) и 1 байт (тип) + // 1. Общий размер (int64) + if err := binary.Write(conn, binary.BigEndian, m.totalBytes); err != nil { + conn.Close() + return nil, fmt.Errorf("failed to send backup size: %w", err) + } + // 2. Тип бэкапа (1 = директория, 0 = файл/диск) + var typeByte byte = 0 + if m.isDirectory { + typeByte = 1 + } + if _, err := conn.Write([]byte{typeByte}); err != nil { + conn.Close() + return nil, fmt.Errorf("failed to send backup type: %w", err) + } + + return conn, nil + } + // Логика для локального файла + return os.Create(m.target) +} + func (m *model) startBackup() tea.Msg { go func() { + targetWriter, err := m.getTargetWriter() + if err != nil { + m.err = err + m.progressCh <- -1 + return + } + defer targetWriter.Close() + + var backupErr error if m.isDirectory { - err := m.backupDirectory() - if err != nil { - m.progressCh <- -1 - return - } - m.progressCh <- -2 - return + backupErr = m.backupDirectory(targetWriter) + } else { + backupErr = m.backupFileOrDisk(targetWriter) } - m.target += ".gz" - // Обработка файлов и дисков - srcFile, err := os.Open(m.source) - if err != nil { + + if backupErr != nil { + m.err = backupErr 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 // Сигнал завершения }() @@ -209,14 +222,37 @@ func (m *model) startBackup() tea.Msg { return nil } -func (m *model) backupDirectory() error { - dstFile, err := os.Create(m.target) +func (m *model) backupFileOrDisk(w io.Writer) error { + srcFile, err := os.Open(m.source) if err != nil { return err } - defer dstFile.Close() + defer srcFile.Close() - gzipWriter := gzip.NewWriter(dstFile) + gzipWriter := gzip.NewWriter(w) + defer gzipWriter.Close() + + buf := make([]byte, 32*1024) // 32KB buffer + for { + n, err := srcFile.Read(buf) + if n > 0 { + if _, writeErr := gzipWriter.Write(buf[:n]); writeErr != nil { + return writeErr + } + m.progressCh <- int64(n) + } + if err == io.EOF { + break + } + if err != nil { + return err + } + } + return nil +} + +func (m *model) backupDirectory(w io.Writer) error { + gzipWriter := gzip.NewWriter(w) defer gzipWriter.Close() tarWriter := tar.NewWriter(gzipWriter) @@ -227,37 +263,31 @@ func (m *model) backupDirectory() error { return err } - // Создаем заголовок для файла/директории header, err := tar.FileInfoHeader(fi, file) if err != nil { return err } - // Устанавливаем относительный путь relPath, err := filepath.Rel(m.source, file) if err != nil { return err } header.Name = relPath - // Пишем заголовок в архив if err := tarWriter.WriteHeader(header); err != nil { return err } - // Если это не обычный файл, пропускаем запись данных if !fi.Mode().IsRegular() { return nil } - // Открываем файл для чтения srcFile, err := os.Open(file) if err != nil { return err } defer srcFile.Close() - // Копируем содержимое файла в архив buf := make([]byte, 32*1024) for { n, err := srcFile.Read(buf) @@ -267,16 +297,13 @@ func (m *model) backupDirectory() error { } m.progressCh <- int64(n) } - if err == io.EOF { break } - if err != nil { return err } } - return nil }) } @@ -286,7 +313,7 @@ func (m *model) progressListener() tea.Msg { case n := <-m.progressCh: switch { case n == -1: // Ошибка - return errorMsg{fmt.Errorf("backup error")} + return errorMsg{m.err} case n == -2: // Завершение return doneMsg{} default: // Прогресс @@ -309,21 +336,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) @@ -342,27 +363,34 @@ func (m *model) View() string { if m.done { duration := time.Since(m.startTime) - ratio := float64(m.processed) / float64(m.totalBytes) * 100 - backupType := "file/disk" + // ratio := float64(m.processed) / float64(m.totalBytes) * 100 + backupType := "File/disk" if m.isDirectory { - backupType = "directory" + backupType = "Directory" + } + if m.isNetworkTarget { + backupType += " network" } return fmt.Sprintf("\n✅ %s backup complete!\n\n"+ "Source: %s\n"+ - "Backup: %s\n"+ - "Size: %s → %s (%.1f%%)\n"+ + "Target: %s\n"+ + "Size: %s\n"+ "Time: %v\n\n", backupType, m.source, m.target, - formatBytes(m.totalBytes), formatBytes(m.processed), ratio, + formatBytes(m.processed), duration.Round(time.Millisecond)) } - backupType := "Backing up" + operation := "Backing up" if m.isDirectory { - backupType = "Archiving" + operation = "Archiving" } - title := fmt.Sprintf("%s %s → %s", backupType, m.source, m.target) + if m.isNetworkTarget { + operation = "Sending" + } + + title := fmt.Sprintf("%s %s → %s", operation, 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) @@ -380,7 +408,7 @@ func (m *model) View() string { func init() { rootCmd.AddCommand(createCmd) createCmd.Flags().StringVarP(&source, "source", "s", "", "Source file, directory or disk (e.g., /dev/sda) (required)") - createCmd.Flags().StringVarP(&target, "target", "t", "", "Target backup file (required)") + createCmd.Flags().StringVarP(&target, "target", "t", "", "Target backup file or network address (e.g., /path/to/backup.tar.gz or 127.0.0.1:8080) (required)") createCmd.MarkFlagRequired("source") createCmd.MarkFlagRequired("target") } diff --git a/cmd/restore.go b/cmd/restore.go index 80ad794..50aec98 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -3,8 +3,10 @@ package cmd import ( "archive/tar" "compress/gzip" + "encoding/binary" "fmt" "io" + "net" "os" "path/filepath" "runtime" @@ -26,7 +28,7 @@ var ( var restoreCmd = &cobra.Command{ Use: "restore", Short: "Restore a backup", - Long: "Restore a backup from an archive to a file, disk, or directory.", + Long: "Restore a backup from a local archive or network stream to a file, disk, or directory.", Run: func(cmd *cobra.Command, args []string) { model, err := initialRestoreModel(restoreSource, restoreTarget) if err != nil { @@ -43,16 +45,18 @@ var restoreCmd = &cobra.Command{ } type restoreModel struct { - progress progress.Model - source string - target string - totalBytes int64 - processed int64 - done bool - err error - startTime time.Time - progressCh chan int64 - isDirectory bool + progress progress.Model + source string + target string + totalBytes int64 + processed int64 + done bool + err error + startTime time.Time + progressCh chan int64 + isDirectory bool + isNetworkSource bool + statusMessage string } type restoreProgressMsg int64 @@ -60,36 +64,50 @@ 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 - } - + isNetwork := isNetworkAddress(src) p := progress.New( progress.WithDefaultGradient(), progress.WithWidth(40), ) - isDirectory := strings.HasSuffix(src, ".tar.gz") + var totalBytes int64 = 1 // Placeholder for network source, to avoid division by zero. + var isDir bool targetPath := dst + status := "Initializing..." - if isDirectory && targetPath == "" { - targetPath, err = os.Getwd() + if isNetwork { + if targetPath == "" { + return nil, fmt.Errorf("target flag -t is required for a network source") + } + status = fmt.Sprintf("Listening on %s...", src) + } else { + fileInfo, err := os.Stat(src) if err != nil { - return nil, fmt.Errorf("failed to get current directory: %w", err) + return nil, fmt.Errorf("failed to stat source %s: %w", src, err) + } + totalBytes = fileInfo.Size() + isDir = strings.HasSuffix(src, ".tar.gz") + + if isDir && targetPath == "" { + targetPath, err = os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get current directory: %w", err) + } + } else if !isDir && targetPath == "" { + return nil, fmt.Errorf("target flag -t is required for file or disk restoration") } - } else if !isDirectory && targetPath == "" { - return nil, fmt.Errorf("target flag is required for file or disk restoration") } return &restoreModel{ - progress: p, - source: src, - target: targetPath, - totalBytes: fileInfo.Size(), - startTime: time.Now(), - progressCh: make(chan int64, 100), - isDirectory: isDirectory, + progress: p, + source: src, + target: targetPath, + totalBytes: totalBytes, + startTime: time.Now(), + progressCh: make(chan int64, 100), + isDirectory: isDir, + isNetworkSource: isNetwork, + statusMessage: status, }, nil } @@ -100,85 +118,108 @@ func (m *restoreModel) Init() tea.Cmd { ) } -func (m *restoreModel) startRestore() tea.Msg { - go func() { - if m.isDirectory { - if err := m.restoreDirectory(); err != nil { - m.progressCh <- -1 // Error signal - return - } - m.progressCh <- -2 // Done signal - return +func (m *restoreModel) getSourceReader() (io.ReadCloser, error) { + if m.isNetworkSource { + listener, err := net.Listen("tcp", m.source) + if err != nil { + return nil, fmt.Errorf("failed to listen on %s: %w", m.source, err) + } + defer listener.Close() // Close listener after accepting one connection + + m.statusMessage = fmt.Sprintf("Waiting for connection on %s", m.source) + // This is a blocking call + conn, err := listener.Accept() + if err != nil { + return nil, fmt.Errorf("failed to accept connection: %w", err) + } + m.statusMessage = "Client connected. Receiving data..." + + // Protocol: Read 8 bytes for size, 1 byte for type + if err := binary.Read(conn, binary.BigEndian, &m.totalBytes); err != nil { + conn.Close() + return nil, fmt.Errorf("failed to read backup size: %w", err) + } + if m.totalBytes == 0 { + m.totalBytes = 1 // Avoid division by zero for empty files } - // Fallback to original file/disk restoration logic - srcFile, err := os.Open(m.source) + typeByte := make([]byte, 1) + if _, err := io.ReadFull(conn, typeByte); err != nil { + conn.Close() + return nil, fmt.Errorf("failed to read backup type: %w", err) + } + m.isDirectory = (typeByte[0] == 1) + + return conn, nil + } + + // Logic for local file + m.statusMessage = fmt.Sprintf("Opening archive %s", m.source) + return os.Open(m.source) +} + +func (m *restoreModel) startRestore() tea.Msg { + go func() { + sourceReader, err := m.getSourceReader() if err != nil { + m.err = err m.progressCh <- -1 return } - defer srcFile.Close() + defer sourceReader.Close() progressReader := &restoreProgressReader{ - reader: srcFile, - total: m.totalBytes, + reader: sourceReader, progressCh: m.progressCh, } - 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 - } + var restoreErr error + if m.isDirectory { + restoreErr = m.restoreDirectory(progressReader) } else { - dstFile, err = os.Create(m.target) - if err != nil { - m.progressCh <- -1 - return - } + restoreErr = m.restoreFileOrDisk(progressReader) } - defer dstFile.Close() - _, err = io.Copy(dstFile, gzipReader) - if err != nil { + if restoreErr != nil { + m.err = restoreErr m.progressCh <- -1 return } - if err := dstFile.Sync(); err != nil { - m.progressCh <- -1 - return - } - - m.progressCh <- -2 // Сигнал завершения + m.progressCh <- -2 // Done signal }() - return nil } -func (m *restoreModel) restoreDirectory() error { - srcFile, err := os.Open(m.source) +func (m *restoreModel) restoreFileOrDisk(r io.Reader) error { + gzipReader, err := gzip.NewReader(r) if err != nil { return err } - defer srcFile.Close() + defer gzipReader.Close() - progressReader := &restoreProgressReader{ - reader: srcFile, - total: m.totalBytes, - progressCh: m.progressCh, + var dstFile *os.File + if isBlockDevice(m.target) { + dstFile, err = os.OpenFile(m.target, os.O_WRONLY|os.O_SYNC, 0) + } else { + if err := os.MkdirAll(filepath.Dir(m.target), 0755); err != nil { + return err + } + dstFile, err = os.Create(m.target) } + if err != nil { + return err + } + defer dstFile.Close() - gzipReader, err := gzip.NewReader(progressReader) + if _, err = io.Copy(dstFile, gzipReader); err != nil { + return err + } + return dstFile.Sync() +} + +func (m *restoreModel) restoreDirectory(r io.Reader) error { + gzipReader, err := gzip.NewReader(r) if err != nil { return err } @@ -210,11 +251,14 @@ func (m *restoreModel) restoreDirectory() error { if err != nil { return err } - if _, err := io.Copy(outFile, tarReader); err != nil { - outFile.Close() - return err + _, copyErr := io.Copy(outFile, tarReader) + closeErr := outFile.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr } - outFile.Close() } } return nil @@ -222,7 +266,6 @@ func (m *restoreModel) restoreDirectory() error { type restoreProgressReader struct { reader io.Reader - total int64 progressCh chan int64 read int64 } @@ -241,7 +284,10 @@ func (m *restoreModel) restoreProgressListener() tea.Msg { case n := <-m.progressCh: switch { case n == -1: // Ошибка - return restoreErrorMsg{fmt.Errorf("restoration error")} + if m.err == nil { + m.err = fmt.Errorf("unknown restoration error") + } + return restoreErrorMsg{m.err} case n == -2: // Завершение return restoreDoneMsg{} default: // Прогресс @@ -268,9 +314,7 @@ func (m *restoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if progressVal > 1.0 { progressVal = 1.0 } - cmd := m.progress.SetPercent(progressVal) - return m, tea.Batch(cmd, m.restoreProgressListener) case restoreDoneMsg: @@ -282,7 +326,6 @@ func (m *restoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = msg.err return m, tea.Quit } - return m, nil } @@ -298,13 +341,19 @@ func (m *restoreModel) View() string { restoreType = "Directory" } return fmt.Sprintf("\n✅ %s restoration complete!\n\n"+ - "Archive: %s → %s\n"+ + "Source: %s\n"+ + "Target: %s\n"+ "Time: %v\n\n", restoreType, m.source, m.target, duration.Round(time.Millisecond)) } - title := fmt.Sprintf("Restoring %s → %s", m.source, m.target) + // Show status message before progress starts + if m.processed == 0 && (m.isNetworkSource || m.statusMessage != "") { + return m.statusMessage + "\n\nPress Ctrl+C to cancel" + } + + title := fmt.Sprintf("Restoring from %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) @@ -323,23 +372,20 @@ 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, disk, or directory. Defaults to current directory for archives.") + restoreCmd.Flags().StringVarP(&restoreSource, "source", "s", "", "Source archive or network address (e.g., /path/to/archive.tar.gz or 0.0.0.0:8080) (required)") + restoreCmd.Flags().StringVarP(&restoreTarget, "target", "t", "", "Target file, disk, or directory. Required for network sources.") restoreCmd.MarkFlagRequired("source") } diff --git a/cmd/utils.go b/cmd/utils.go index 532486b..b99f06e 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -1,6 +1,14 @@ package cmd -import "fmt" +import ( + "fmt" + "strings" +) + +// isNetworkAddress checks if the address is in host:port format +func isNetworkAddress(addr string) bool { + return strings.Contains(addr, ":") +} func formatBytes(b int64) string { const unit = 1024