Fix move duplicate handling with hash verification
This commit is contained in:
+284
-17
@@ -1,15 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type contentRecord struct {
|
||||
path string
|
||||
kind byte
|
||||
size int64
|
||||
sum [32]byte
|
||||
}
|
||||
|
||||
func ensureExistingDir(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
@@ -162,11 +173,38 @@ func processArchiveCandidate(cfg config, sourcePath string) (string, bool, error
|
||||
zipTarget := filepath.Join(cfg.archive.zipDir, fileName)
|
||||
|
||||
if pathExists(zipTarget) {
|
||||
if cfg.mode == "move" {
|
||||
same, err := filesHaveSameHash(sourcePath, zipTarget)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if same {
|
||||
if err := os.Remove(sourcePath); err != nil {
|
||||
return "", false, fmt.Errorf("remove duplicate archive source: %w", err)
|
||||
}
|
||||
return "已跳过传输,zip 目录已有同名压缩包且哈希一致,源文件已删除", true, nil
|
||||
}
|
||||
return "已跳过,zip 目录中存在同名压缩包但哈希不同", false, nil
|
||||
}
|
||||
return "已跳过,zip 目录中存在同名压缩包", false, nil
|
||||
}
|
||||
|
||||
unzipTarget := filepath.Join(cfg.archive.unzipDir, extractedDirName(fileName))
|
||||
extractedDir := extractedDirName(fileName)
|
||||
unzipTarget := filepath.Join(cfg.archive.unzipDir, extractedDir)
|
||||
if isDir(unzipTarget) {
|
||||
if cfg.mode == "move" {
|
||||
same, err := archiveMatchesDirectory(sourcePath, unzipTarget, extractedDir)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if same {
|
||||
if err := os.Remove(sourcePath); err != nil {
|
||||
return "", false, fmt.Errorf("remove duplicate archive source: %w", err)
|
||||
}
|
||||
return "已跳过传输,unzip 目录已有同名解压目录且内容一致,源文件已删除", true, nil
|
||||
}
|
||||
return "已跳过,unzip 目录中存在同名解压目录但内容不同", false, nil
|
||||
}
|
||||
return "已跳过,unzip 目录中存在同名解压目录", false, nil
|
||||
}
|
||||
|
||||
@@ -185,6 +223,10 @@ func processGenericCandidate(cfg config, sourcePath string) (string, bool, error
|
||||
targetPath := filepath.Join(cfg.generic.targetDir, fileName)
|
||||
|
||||
if pathExists(targetPath) {
|
||||
if cfg.mode == "move" {
|
||||
return handleGenericDuplicateMove(sourcePath, targetPath, cfg.generic.dedupeMode)
|
||||
}
|
||||
|
||||
decision, duplicate, err := evaluateGenericDuplicate(sourcePath, targetPath, cfg.generic.dedupeMode)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
@@ -206,40 +248,70 @@ func processGenericCandidate(cfg config, sourcePath string) (string, bool, error
|
||||
}
|
||||
|
||||
func evaluateGenericDuplicate(sourcePath, targetPath, dedupeMode string) (string, bool, error) {
|
||||
decision, duplicate, _, err := analyzeGenericDuplicate(sourcePath, targetPath, dedupeMode)
|
||||
return decision, duplicate, err
|
||||
}
|
||||
|
||||
func analyzeGenericDuplicate(sourcePath, targetPath, dedupeMode string) (string, bool, bool, error) {
|
||||
sourceInfo, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("stat source: %w", err)
|
||||
return "", false, false, fmt.Errorf("stat source: %w", err)
|
||||
}
|
||||
|
||||
targetInfo, err := os.Stat(targetPath)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("stat target: %w", err)
|
||||
return "", false, false, fmt.Errorf("stat target: %w", err)
|
||||
}
|
||||
|
||||
switch dedupeMode {
|
||||
case genericDedupeNameHash:
|
||||
if sourceInfo.Size() != targetInfo.Size() {
|
||||
return "已跳过,同名文件已存在但大小不同", false, nil
|
||||
return "已跳过,同名文件已存在但大小不同", false, false, nil
|
||||
}
|
||||
same, err := filesHaveSameHash(sourcePath, targetPath)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
return "", false, false, err
|
||||
}
|
||||
if same {
|
||||
return "已跳过,同名文件已存在且哈希相同", true, nil
|
||||
return "已跳过,同名文件已存在且哈希相同", true, true, nil
|
||||
}
|
||||
return "已跳过,同名文件已存在但哈希不同", false, nil
|
||||
return "已跳过,同名文件已存在但哈希不同", false, true, nil
|
||||
|
||||
case genericDedupeNameSize:
|
||||
fallthrough
|
||||
default:
|
||||
if sourceInfo.Size() == targetInfo.Size() {
|
||||
return "已跳过,同名文件已存在且大小相同", true, nil
|
||||
return "已跳过,同名文件已存在且大小相同", true, false, nil
|
||||
}
|
||||
return "已跳过,同名文件已存在但大小不同", false, nil
|
||||
return "已跳过,同名文件已存在但大小不同", false, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleGenericDuplicateMove(sourcePath, targetPath, dedupeMode string) (string, bool, error) {
|
||||
decision, duplicate, hashVerified, err := analyzeGenericDuplicate(sourcePath, targetPath, dedupeMode)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !duplicate {
|
||||
return decision, false, nil
|
||||
}
|
||||
|
||||
if !hashVerified {
|
||||
same, err := filesHaveSameHash(sourcePath, targetPath)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !same {
|
||||
return "已跳过,同名文件大小相同但哈希不同", false, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Remove(sourcePath); err != nil {
|
||||
return "", false, fmt.Errorf("remove duplicate generic source: %w", err)
|
||||
}
|
||||
return "已跳过传输,目标目录已有同名文件且哈希一致,源文件已删除", true, nil
|
||||
}
|
||||
|
||||
func filesHaveSameHash(sourcePath, targetPath string) (bool, error) {
|
||||
sourceHash, err := fileSHA256(sourcePath)
|
||||
if err != nil {
|
||||
@@ -254,6 +326,192 @@ func filesHaveSameHash(sourcePath, targetPath string) (bool, error) {
|
||||
return sourceHash == targetHash, nil
|
||||
}
|
||||
|
||||
func archiveMatchesDirectory(sourcePath, targetPath, archiveRoot string) (bool, error) {
|
||||
sourceRecords, err := archiveContentRecords(sourcePath, archiveRoot)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
targetRecords, err := directoryContentRecords(targetPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if len(sourceRecords) != len(targetRecords) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for i := range sourceRecords {
|
||||
if sourceRecords[i] != targetRecords[i] {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func archiveContentRecords(archivePath, archiveRoot string) ([]contentRecord, error) {
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open archive: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gzipReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open gzip reader: %w", err)
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
|
||||
records := make(map[string]contentRecord)
|
||||
tarReader := tar.NewReader(gzipReader)
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read archive entry: %w", err)
|
||||
}
|
||||
|
||||
name := normalizeArchiveRecordPath(header.Name, archiveRoot)
|
||||
switch header.Typeflag {
|
||||
case tar.TypeReg, tar.TypeRegA, tar.TypeGNUSparse:
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("archive entry %q resolves to empty path", header.Name)
|
||||
}
|
||||
|
||||
sum, err := readerSHA256(tarReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash archive entry %q: %w", header.Name, err)
|
||||
}
|
||||
records[name] = contentRecord{
|
||||
path: name,
|
||||
kind: 'F',
|
||||
size: header.Size,
|
||||
sum: sum,
|
||||
}
|
||||
|
||||
case tar.TypeSymlink:
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("archive symlink %q resolves to empty path", header.Name)
|
||||
}
|
||||
|
||||
records[name] = contentRecord{
|
||||
path: name,
|
||||
kind: 'L',
|
||||
sum: sha256.Sum256([]byte(filepath.ToSlash(header.Linkname))),
|
||||
}
|
||||
|
||||
case tar.TypeDir, tar.TypeXHeader, tar.TypeXGlobalHeader, tar.TypeGNULongName, tar.TypeGNULongLink:
|
||||
continue
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported archive entry type for %q: %d", header.Name, header.Typeflag)
|
||||
}
|
||||
}
|
||||
|
||||
return sortedContentRecords(records), nil
|
||||
}
|
||||
|
||||
func directoryContentRecords(root string) ([]contentRecord, error) {
|
||||
records := make(map[string]contentRecord)
|
||||
|
||||
err := filepath.Walk(root, func(currentPath string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if currentPath == root {
|
||||
return nil
|
||||
}
|
||||
|
||||
relativePath, err := filepath.Rel(root, currentPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build relative path for %q: %w", currentPath, err)
|
||||
}
|
||||
relativePath = filepath.ToSlash(relativePath)
|
||||
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
linkTarget, err := os.Readlink(currentPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read symlink %q: %w", currentPath, err)
|
||||
}
|
||||
records[relativePath] = contentRecord{
|
||||
path: relativePath,
|
||||
kind: 'L',
|
||||
sum: sha256.Sum256([]byte(filepath.ToSlash(linkTarget))),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("unsupported directory entry %q", currentPath)
|
||||
}
|
||||
|
||||
sum, err := fileSHA256(currentPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash directory file %q: %w", currentPath, err)
|
||||
}
|
||||
records[relativePath] = contentRecord{
|
||||
path: relativePath,
|
||||
kind: 'F',
|
||||
size: info.Size(),
|
||||
sum: sum,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sortedContentRecords(records), nil
|
||||
}
|
||||
|
||||
func normalizeArchiveRecordPath(name, archiveRoot string) string {
|
||||
cleanPath := strings.TrimSpace(filepath.ToSlash(name))
|
||||
cleanPath = strings.TrimPrefix(cleanPath, "./")
|
||||
cleanPath = strings.TrimPrefix(cleanPath, "/")
|
||||
cleanPath = path.Clean(cleanPath)
|
||||
|
||||
if cleanPath == "." {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalizedRoot := path.Clean(strings.Trim(filepath.ToSlash(archiveRoot), "/"))
|
||||
if normalizedRoot != "." && normalizedRoot != "" {
|
||||
if cleanPath == normalizedRoot {
|
||||
return ""
|
||||
}
|
||||
prefix := normalizedRoot + "/"
|
||||
if strings.HasPrefix(cleanPath, prefix) {
|
||||
cleanPath = strings.TrimPrefix(cleanPath, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
if cleanPath == "." {
|
||||
return ""
|
||||
}
|
||||
return cleanPath
|
||||
}
|
||||
|
||||
func sortedContentRecords(records map[string]contentRecord) []contentRecord {
|
||||
items := make([]contentRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
items = append(items, record)
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].path == items[j].path {
|
||||
return items[i].kind < items[j].kind
|
||||
}
|
||||
return items[i].path < items[j].path
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func fileSHA256(path string) ([32]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
@@ -261,8 +519,12 @@ func fileSHA256(path string) ([32]byte, error) {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
return readerSHA256(file)
|
||||
}
|
||||
|
||||
func readerSHA256(reader io.Reader) ([32]byte, error) {
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
if _, err := io.Copy(hasher, reader); err != nil {
|
||||
return [32]byte{}, err
|
||||
}
|
||||
|
||||
@@ -288,22 +550,27 @@ func transferFile(sourcePath, targetPath, mode string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("open source: %w", err)
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
target, err := os.OpenFile(tempPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
_ = source.Close()
|
||||
return fmt.Errorf("create temp target: %w", err)
|
||||
}
|
||||
|
||||
copyErr := copyFileContents(target, source)
|
||||
closeErr := target.Close()
|
||||
if copyErr != nil {
|
||||
if err := copyFileContents(target, source); err != nil {
|
||||
_ = target.Close()
|
||||
_ = source.Close()
|
||||
_ = os.Remove(tempPath)
|
||||
return copyErr
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
if err := target.Close(); err != nil {
|
||||
_ = source.Close()
|
||||
_ = os.Remove(tempPath)
|
||||
return fmt.Errorf("close temp target: %w", closeErr)
|
||||
return fmt.Errorf("close temp target: %w", err)
|
||||
}
|
||||
if err := source.Close(); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return fmt.Errorf("close source: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tempPath, targetPath); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user