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,
}