diff --git a/app.go b/app.go index 09d8a60..cd48afc 100644 --- a/app.go +++ b/app.go @@ -167,7 +167,7 @@ func (a *App) Delete(connId, path string) error { return exp.Delete(path) } -func (a *App) QueueTransfer(id string, srcConnId, dstConnId, srcPath, dstPath, filename string, size int64) error { +func (a *App) QueueTransfer(id string, srcConnId, dstConnId, srcPath, dstPath, filename string, size int64, verify bool) error { srcExp, err := a.getExplorerForConnection(srcConnId) if err != nil { return err @@ -185,7 +185,7 @@ func (a *App) QueueTransfer(id string, srcConnId, dstConnId, srcPath, dstPath, f return err } - return a.transferManager.QueueTransfer(id, srcPath, dstPath, filename, size, srcExp, dstExp) + return a.transferManager.QueueTransfer(id, srcPath, dstPath, filename, size, srcExp, dstExp, verify) } func (a *App) GetTransfers() []*transfer.Transfer { @@ -232,7 +232,7 @@ func (a *App) PromptUploadFiles(connId, destPath string) error { remotePath = destPath + fileName } - a.QueueTransfer(id, "local", connId, localPath, remotePath, fileName, stat.Size()) + a.QueueTransfer(id, "local", connId, localPath, remotePath, fileName, stat.Size(), false) } return nil } @@ -272,7 +272,7 @@ func (a *App) PromptUploadDirectory(connId, destPath string) error { } id := uuid.New().String() - a.QueueTransfer(id, "local", connId, path, remotePath, info.Name(), info.Size()) + a.QueueTransfer(id, "local", connId, path, remotePath, info.Name(), info.Size(), false) return nil }) } @@ -304,7 +304,7 @@ func (a *App) PromptDownload(connId, remotePath string) error { size = stat.Size } - return a.QueueTransfer(id, connId, "local", remotePath, localPath, fileName, size) + return a.QueueTransfer(id, connId, "local", remotePath, localPath, fileName, size, false) } type TransferItem struct { @@ -314,7 +314,7 @@ type TransferItem struct { Size int64 `json:"size"` } -func (a *App) TransferItems(srcConnId, dstConnId, dstPath string, items []TransferItem) error { +func (a *App) TransferItems(srcConnId, dstConnId, dstPath string, items []TransferItem, verify bool) error { for _, item := range items { if !item.IsDir { id := uuid.New().String() @@ -328,18 +328,18 @@ func (a *App) TransferItems(srcConnId, dstConnId, dstPath string, items []Transf remotePath = dstPath + item.Name } - if err := a.QueueTransfer(id, srcConnId, dstConnId, item.Path, remotePath, item.Name, item.Size); err != nil { + if err := a.QueueTransfer(id, srcConnId, dstConnId, item.Path, remotePath, item.Name, item.Size, verify); err != nil { return err } } else { // Run remote walk in goroutine to not block UI - go a.transferRemoteDirectory(srcConnId, dstConnId, item.Path, dstPath) + go a.transferRemoteDirectory(srcConnId, dstConnId, item.Path, dstPath, verify) } } return nil } -func (a *App) transferRemoteDirectory(srcConnId, dstConnId, srcDirPath, dstBasePath string) { +func (a *App) transferRemoteDirectory(srcConnId, dstConnId, srcDirPath, dstBasePath string, verify bool) { baseName := filepath.Base(srcDirPath) newDstPath := dstBasePath @@ -360,11 +360,11 @@ func (a *App) transferRemoteDirectory(srcConnId, dstConnId, srcDirPath, dstBaseP for _, entry := range entries { if entry.IsDir { - a.transferRemoteDirectory(srcConnId, dstConnId, entry.Path, newDstPath) + a.transferRemoteDirectory(srcConnId, dstConnId, entry.Path, newDstPath, verify) } else { id := uuid.New().String() itemDstPath := newDstPath + "/" + entry.Name - a.QueueTransfer(id, srcConnId, dstConnId, entry.Path, itemDstPath, entry.Name, entry.Size) + a.QueueTransfer(id, srcConnId, dstConnId, entry.Path, itemDstPath, entry.Name, entry.Size, verify) } } } diff --git a/frontend/index.html b/frontend/index.html index c7bf3ee..81e6a68 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -176,6 +176,15 @@ + +
+ + +
+
diff --git a/frontend/src/main.js b/frontend/src/main.js index 7a1dcbe..29d6be4 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -528,6 +528,7 @@ window.openTransferModal = () => { window.closeTransferModal = () => { document.getElementById('transfer-modal').style.display = 'none'; + document.getElementById('transfer-verify').checked = false; }; window.loadTransferDestRoot = () => { diff --git a/internal/explorer/explorer.go b/internal/explorer/explorer.go index 0224ed3..9fbb28b 100644 --- a/internal/explorer/explorer.go +++ b/internal/explorer/explorer.go @@ -21,4 +21,5 @@ type Explorer interface { Rename(src, dst string) error ReadFile(path string) (io.ReadCloser, error) WriteFile(path string, r io.Reader, size int64) error + Checksum(path string) (string, error) } diff --git a/internal/protocols/ftp/ftp.go b/internal/protocols/ftp/ftp.go index 3b03a18..ce1dfec 100644 --- a/internal/protocols/ftp/ftp.go +++ b/internal/protocols/ftp/ftp.go @@ -1,7 +1,9 @@ package ftp import ( + "crypto/md5" "crypto/tls" + "encoding/hex" "fmt" "io" "path/filepath" @@ -177,3 +179,16 @@ func (e *FTPExplorer) ReadFile(path string) (io.ReadCloser, error) { func (e *FTPExplorer) WriteFile(path string, r io.Reader, size int64) error { return e.client.Stor(path, r) } + +func (e *FTPExplorer) Checksum(path string) (string, error) { + r, err := e.ReadFile(path) + if err != nil { + return "", err + } + defer r.Close() + h := md5.New() + if _, err := io.Copy(h, r); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/protocols/local/local.go b/internal/protocols/local/local.go index b0c73ab..1d353bb 100644 --- a/internal/protocols/local/local.go +++ b/internal/protocols/local/local.go @@ -1,6 +1,8 @@ package local import ( + "crypto/md5" + "encoding/hex" "io" "os" "path/filepath" @@ -82,6 +84,19 @@ func (e *LocalExplorer) Rename(src, dst string) error { return os.Rename(src, dst) } +func (e *LocalExplorer) Checksum(path string) (string, error) { + r, err := e.ReadFile(path) + if err != nil { + return "", err + } + defer r.Close() + h := md5.New() + if _, err := io.Copy(h, r); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + func (e *LocalExplorer) ReadFile(path string) (io.ReadCloser, error) { return os.Open(path) } diff --git a/internal/protocols/nfs/nfs_unix.go b/internal/protocols/nfs/nfs_unix.go index 8f74ee2..30cae20 100644 --- a/internal/protocols/nfs/nfs_unix.go +++ b/internal/protocols/nfs/nfs_unix.go @@ -3,6 +3,8 @@ package nfs import ( + "crypto/md5" + "encoding/hex" "fmt" "io" "path/filepath" @@ -125,3 +127,16 @@ func (e *NFSExplorer) WriteFile(path string, r io.Reader, size int64) error { _, err = io.Copy(f, r) return err } + +func (e *NFSExplorer) Checksum(path string) (string, error) { + r, err := e.ReadFile(path) + if err != nil { + return "", err + } + defer r.Close() + h := md5.New() + if _, err := io.Copy(h, r); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/protocols/nfs/nfs_windows.go b/internal/protocols/nfs/nfs_windows.go index 24b005d..59501a6 100644 --- a/internal/protocols/nfs/nfs_windows.go +++ b/internal/protocols/nfs/nfs_windows.go @@ -25,3 +25,4 @@ func (e *NFSExplorer) Delete(path string) error { return nil } func (e *NFSExplorer) Rename(src, dst string) error { return nil } func (e *NFSExplorer) ReadFile(path string) (io.ReadCloser, error) { return nil, nil } func (e *NFSExplorer) WriteFile(path string, r io.Reader, size int64) error { return nil } +func (e *NFSExplorer) Checksum(path string) (string, error) { return "", nil } diff --git a/internal/protocols/s3/s3.go b/internal/protocols/s3/s3.go index 33b609f..cbded41 100644 --- a/internal/protocols/s3/s3.go +++ b/internal/protocols/s3/s3.go @@ -307,3 +307,28 @@ func (e *S3Explorer) WriteFile(path string, r io.Reader, size int64) error { }) return err } + +func (e *S3Explorer) Checksum(path string) (string, error) { + bucket, subpath, _, err := e.getBucketAndPath(path) + if err != nil { + return "", err + } + + if subpath == "" { + return "", fmt.Errorf("cannot calculate checksum of bucket") + } + + input := &s3.HeadObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(subpath), + } + head, err := e.client.HeadObject(context.TODO(), input) + if err != nil { + return "", err + } + + if head.ETag != nil { + return strings.Trim(*head.ETag, "\""), nil + } + return "", fmt.Errorf("no etag found") +} diff --git a/internal/protocols/sftp/sftp.go b/internal/protocols/sftp/sftp.go index 1918ee9..4e37775 100644 --- a/internal/protocols/sftp/sftp.go +++ b/internal/protocols/sftp/sftp.go @@ -1,6 +1,8 @@ package sftp import ( + "crypto/md5" + "encoding/hex" "fmt" "io" "path/filepath" @@ -162,3 +164,16 @@ func (e *SFTPExplorer) WriteFile(path string, r io.Reader, size int64) error { _, err = io.Copy(f, r) return err } + +func (e *SFTPExplorer) Checksum(path string) (string, error) { + r, err := e.ReadFile(path) + if err != nil { + return "", err + } + defer r.Close() + h := md5.New() + if _, err := io.Copy(h, r); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/protocols/smb/smb.go b/internal/protocols/smb/smb.go index 22c7fd3..fcd44db 100644 --- a/internal/protocols/smb/smb.go +++ b/internal/protocols/smb/smb.go @@ -1,6 +1,8 @@ package smb import ( + "crypto/md5" + "encoding/hex" "fmt" "io" "net" @@ -243,3 +245,16 @@ func (e *SMBExplorer) WriteFile(path string, r io.Reader, size int64) error { _, err = io.Copy(f, r) return err } + +func (e *SMBExplorer) Checksum(path string) (string, error) { + r, err := e.ReadFile(path) + if err != nil { + return "", err + } + defer r.Close() + h := md5.New() + if _, err := io.Copy(h, r); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/protocols/webdav/webdav.go b/internal/protocols/webdav/webdav.go index 5ecb3bf..9203a7b 100644 --- a/internal/protocols/webdav/webdav.go +++ b/internal/protocols/webdav/webdav.go @@ -1,6 +1,8 @@ package webdav import ( + "crypto/md5" + "encoding/hex" "fmt" "io" "strings" @@ -102,3 +104,16 @@ func (e *WebDAVExplorer) ReadFile(path string) (io.ReadCloser, error) { func (e *WebDAVExplorer) WriteFile(path string, r io.Reader, size int64) error { return e.client.WriteStream(path, r, 0644) } + +func (e *WebDAVExplorer) Checksum(path string) (string, error) { + r, err := e.ReadFile(path) + if err != nil { + return "", err + } + defer r.Close() + h := md5.New() + if _, err := io.Copy(h, r); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/transfer/transfer.go b/internal/transfer/transfer.go index 4b9b6d8..d0a0432 100644 --- a/internal/transfer/transfer.go +++ b/internal/transfer/transfer.go @@ -1,8 +1,12 @@ package transfer import ( + "crypto/md5" + "encoding/hex" "errors" + "fmt" "io" + "strings" "sync" "time" @@ -29,6 +33,7 @@ type Transfer struct { ETA int `json:"eta_seconds"` Status Status `json:"status"` Error string `json:"error,omitempty"` + Verify bool `json:"verify"` srcExp explorer.Explorer dstExp explorer.Explorer @@ -81,12 +86,36 @@ func (m *Manager) doTransfer(t *Transfer) error { } defer r.Close() + hash := md5.New() + tr := io.TeeReader(r, hash) + pr := &progressReader{ - r: r, + r: tr, t: t, } - return t.dstExp.WriteFile(t.Destination, pr, t.BytesTotal) + if err := t.dstExp.WriteFile(t.Destination, pr, t.BytesTotal); err != nil { + return err + } + + if t.Verify { + expectedHash := hex.EncodeToString(hash.Sum(nil)) + t.Status = StatusActive // Still active during verification + + actualHash, err := t.dstExp.Checksum(t.Destination) + if err != nil { + return fmt.Errorf("verification failed: could not calculate destination checksum: %w", err) + } + + if expectedHash != actualHash { + // S3 Multi-part ETags end with -N, we can't easily verify them with local md5 + if !strings.Contains(actualHash, "-") { + return fmt.Errorf("verification failed: checksum mismatch (expected %s, got %s)", expectedHash, actualHash) + } + } + } + + return nil } type progressReader struct { @@ -109,7 +138,7 @@ func (pr *progressReader) Read(p []byte) (int, error) { return n, err } -func (m *Manager) QueueTransfer(id, srcPath, dstPath, filename string, size int64, srcExp, dstExp explorer.Explorer) error { +func (m *Manager) QueueTransfer(id, srcPath, dstPath, filename string, size int64, srcExp, dstExp explorer.Explorer, verify bool) error { if srcExp == nil || dstExp == nil { return errors.New("invalid explorers") } @@ -121,6 +150,7 @@ func (m *Manager) QueueTransfer(id, srcPath, dstPath, filename string, size int6 Filename: filename, BytesTotal: size, Status: StatusQueued, + Verify: verify, srcExp: srcExp, dstExp: dstExp, }