Continue directory uploads past unreadable paths
All checks were successful
Build / Test and build (push) Successful in 5m29s
All checks were successful
Build / Test and build (push) Successful in 5m29s
This commit is contained in:
@@ -268,14 +268,15 @@ func (a *application) manageCommand(use, short string, fn func(context.Context,
|
||||
}
|
||||
|
||||
func (a *application) uploadCommand() *cobra.Command {
|
||||
return &cobra.Command{Use: "upload <本地路径> <远端路径>", Args: cobra.ExactArgs(2), Short: "上传文件或递归上传目录", RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var excludes []string
|
||||
cmd := &cobra.Command{Use: "upload <本地路径> <远端路径>", Args: cobra.ExactArgs(2), Short: "上传文件或递归上传目录", RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _, err := a.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stderr := cmd.ErrOrStderr()
|
||||
lineOpen := false
|
||||
summary, err := uploadLocalPath(cmd.Context(), client, args[0], args[1], func(localPath, remotePath string, done, total int64) {
|
||||
summary, err := uploadLocalPath(cmd.Context(), client, args[0], args[1], uploadPathOptions{Excludes: excludes, Progress: func(localPath, remotePath string, done, total int64) {
|
||||
percent := int64(0)
|
||||
if total > 0 {
|
||||
percent = done * 100 / total
|
||||
@@ -285,13 +286,13 @@ func (a *application) uploadCommand() *cobra.Command {
|
||||
if !lineOpen {
|
||||
fmt.Fprintln(stderr)
|
||||
}
|
||||
}, func(localPath, reason string) {
|
||||
}, Skipped: func(localPath, reason string) {
|
||||
if lineOpen {
|
||||
fmt.Fprintln(stderr)
|
||||
lineOpen = false
|
||||
}
|
||||
fmt.Fprintf(stderr, "跳过 %s: %s\n", localPath, reason)
|
||||
})
|
||||
}})
|
||||
if lineOpen {
|
||||
fmt.Fprintln(stderr)
|
||||
}
|
||||
@@ -301,6 +302,8 @@ func (a *application) uploadCommand() *cobra.Command {
|
||||
fmt.Fprintf(stderr, "完成:上传 %d 个文件(%s),处理 %d 个目录,跳过 %d 项\n", summary.Files, formatBytes(summary.Bytes), summary.Directories, summary.Skipped)
|
||||
return nil
|
||||
}}
|
||||
cmd.Flags().StringArrayVar(&excludes, "exclude", nil, "排除源目录中的 glob(可重复,支持 **)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (a *application) downloadCommand() *cobra.Command {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
@@ -28,7 +29,19 @@ type uploadPathSummary struct {
|
||||
type uploadPathProgress func(localPath, remotePath string, uploaded, total int64)
|
||||
type uploadPathSkipped func(localPath, reason string)
|
||||
|
||||
func uploadLocalPath(ctx context.Context, client uploadPathClient, localSource, remoteDestination string, progress uploadPathProgress, skipped uploadPathSkipped) (uploadPathSummary, error) {
|
||||
type uploadPathOptions struct {
|
||||
Excludes []string
|
||||
Progress uploadPathProgress
|
||||
Skipped uploadPathSkipped
|
||||
}
|
||||
|
||||
type uploadWalkDir func(string, fs.WalkDirFunc) error
|
||||
|
||||
func uploadLocalPath(ctx context.Context, client uploadPathClient, localSource, remoteDestination string, options uploadPathOptions) (uploadPathSummary, error) {
|
||||
excludes, err := normalizeUploadExcludes(options.Excludes)
|
||||
if err != nil {
|
||||
return uploadPathSummary{}, err
|
||||
}
|
||||
info, err := os.Stat(localSource)
|
||||
if err != nil {
|
||||
return uploadPathSummary{}, fmt.Errorf("stat local source: %w", err)
|
||||
@@ -39,7 +52,11 @@ func uploadLocalPath(ctx context.Context, client uploadPathClient, localSource,
|
||||
return uploadPathSummary{}, fmt.Errorf("unsupported local file type: %s", localSource)
|
||||
}
|
||||
var summary uploadPathSummary
|
||||
err := uploadRegularFile(ctx, client, localSource, remoteDestination, info, progress, &summary)
|
||||
if matchesUploadExclude(filepath.Base(localSource), excludes) {
|
||||
reportUploadSkip(&summary, options, localSource, "已被 --exclude 规则排除")
|
||||
return summary, nil
|
||||
}
|
||||
err := uploadRegularFile(ctx, client, localSource, remoteDestination, info, options.Progress, &summary)
|
||||
return summary, err
|
||||
}
|
||||
|
||||
@@ -51,13 +68,25 @@ func uploadLocalPath(ctx context.Context, client uploadPathClient, localSource,
|
||||
if err != nil {
|
||||
return uploadPathSummary{}, fmt.Errorf("resolve local source links: %w", err)
|
||||
}
|
||||
options.Excludes = excludes
|
||||
return uploadLocalDirectory(ctx, client, root, remoteDestination, options, filepath.WalkDir)
|
||||
}
|
||||
|
||||
func uploadLocalDirectory(ctx context.Context, client uploadPathClient, root, remoteDestination string, options uploadPathOptions, walk uploadWalkDir) (uploadPathSummary, error) {
|
||||
if err := client.MkdirAll(ctx, remoteDestination); err != nil {
|
||||
return uploadPathSummary{}, err
|
||||
}
|
||||
summary := uploadPathSummary{Directories: 1}
|
||||
err = filepath.WalkDir(root, func(localPath string, entry fs.DirEntry, walkErr error) error {
|
||||
err := walk(root, func(localPath string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return fmt.Errorf("read local path %s: %w", localPath, walkErr)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
reportUploadSkip(&summary, options, localPath, "无法读取: "+walkErr.Error())
|
||||
if entry != nil && entry.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
@@ -69,7 +98,15 @@ func uploadLocalPath(ctx context.Context, client uploadPathClient, localSource,
|
||||
if err != nil {
|
||||
return fmt.Errorf("make local path relative: %w", err)
|
||||
}
|
||||
remotePath := pathpkg.Join(remoteDestination, filepath.ToSlash(relative))
|
||||
relative = filepath.ToSlash(relative)
|
||||
if matchesUploadExclude(relative, options.Excludes) {
|
||||
reportUploadSkip(&summary, options, localPath, "已被 --exclude 规则排除")
|
||||
if entry.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
remotePath := pathpkg.Join(remoteDestination, relative)
|
||||
if entry.IsDir() {
|
||||
if err := client.EnsureDirectory(ctx, remotePath); err != nil {
|
||||
return err
|
||||
@@ -78,31 +115,23 @@ func uploadLocalPath(ctx context.Context, client uploadPathClient, localSource,
|
||||
return nil
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
summary.Skipped++
|
||||
if skipped != nil {
|
||||
skipped(localPath, "符号链接不会被跟随")
|
||||
}
|
||||
reportUploadSkip(&summary, options, localPath, "符号链接不会被跟随")
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat local path %s: %w", localPath, err)
|
||||
reportUploadSkip(&summary, options, localPath, "无法读取文件信息: "+err.Error())
|
||||
return nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
summary.Skipped++
|
||||
if skipped != nil {
|
||||
skipped(localPath, "不支持的本地文件类型 "+info.Mode().Type().String())
|
||||
}
|
||||
reportUploadSkip(&summary, options, localPath, "不支持的本地文件类型 "+info.Mode().Type().String())
|
||||
return nil
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
summary.Skipped++
|
||||
if skipped != nil {
|
||||
skipped(localPath, "百度开放 API 不支持空文件")
|
||||
}
|
||||
reportUploadSkip(&summary, options, localPath, "百度开放 API 不支持空文件")
|
||||
return nil
|
||||
}
|
||||
return uploadRegularFile(ctx, client, localPath, remotePath, info, progress, &summary)
|
||||
return uploadRegularFile(ctx, client, localPath, remotePath, info, options.Progress, &summary)
|
||||
})
|
||||
if err != nil {
|
||||
return summary, err
|
||||
@@ -110,6 +139,13 @@ func uploadLocalPath(ctx context.Context, client uploadPathClient, localSource,
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func reportUploadSkip(summary *uploadPathSummary, options uploadPathOptions, localPath, reason string) {
|
||||
summary.Skipped++
|
||||
if options.Skipped != nil {
|
||||
options.Skipped(localPath, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func uploadRegularFile(ctx context.Context, client uploadPathClient, localPath, remotePath string, info fs.FileInfo, progress uploadPathProgress, summary *uploadPathSummary) error {
|
||||
completeReported := false
|
||||
if progress != nil && info.Size() > 0 {
|
||||
@@ -135,3 +171,69 @@ func uploadRegularFile(ctx context.Context, client uploadPathClient, localPath,
|
||||
func cleanUploadRemotePath(name string) string {
|
||||
return pathpkg.Clean("/" + strings.TrimPrefix(name, "/"))
|
||||
}
|
||||
|
||||
func normalizeUploadExcludes(patterns []string) ([]string, error) {
|
||||
result := make([]string, 0, len(patterns))
|
||||
for _, pattern := range patterns {
|
||||
pattern = filepath.ToSlash(pattern)
|
||||
pattern = strings.TrimPrefix(pattern, "./")
|
||||
pattern = strings.Trim(pattern, "/")
|
||||
if pattern == "" {
|
||||
return nil, errors.New("exclude pattern cannot be empty")
|
||||
}
|
||||
for _, component := range strings.Split(pattern, "/") {
|
||||
if component == "**" {
|
||||
continue
|
||||
}
|
||||
if _, err := pathpkg.Match(component, ""); err != nil {
|
||||
return nil, fmt.Errorf("invalid exclude pattern %q: %w", pattern, err)
|
||||
}
|
||||
}
|
||||
result = append(result, pattern)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func matchesUploadExclude(relative string, patterns []string) bool {
|
||||
relative = strings.Trim(filepath.ToSlash(relative), "/")
|
||||
for _, pattern := range patterns {
|
||||
if !strings.Contains(pattern, "/") {
|
||||
matched, _ := pathpkg.Match(pattern, pathpkg.Base(relative))
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if matchUploadPathSegments(strings.Split(pattern, "/"), strings.Split(relative, "/")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchUploadPathSegments(pattern, name []string) bool {
|
||||
type state struct{ pattern, name int }
|
||||
memo := make(map[state]bool)
|
||||
seen := make(map[state]bool)
|
||||
var match func(int, int) bool
|
||||
match = func(patternIndex, nameIndex int) bool {
|
||||
key := state{patternIndex, nameIndex}
|
||||
if seen[key] {
|
||||
return memo[key]
|
||||
}
|
||||
seen[key] = true
|
||||
var result bool
|
||||
switch {
|
||||
case patternIndex == len(pattern):
|
||||
result = nameIndex == len(name)
|
||||
case pattern[patternIndex] == "**":
|
||||
result = match(patternIndex+1, nameIndex) || (nameIndex < len(name) && match(patternIndex, nameIndex+1))
|
||||
case nameIndex < len(name):
|
||||
segmentMatches, _ := pathpkg.Match(pattern[patternIndex], name[nameIndex])
|
||||
result = segmentMatches && match(patternIndex+1, nameIndex+1)
|
||||
}
|
||||
memo[key] = result
|
||||
return result
|
||||
}
|
||||
return match(0, 0)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dddbg.com/youbin/bdrclone/internal/baidu"
|
||||
@@ -64,9 +66,9 @@ func TestUploadLocalDirectoryPreservesRelativePaths(t *testing.T) {
|
||||
|
||||
client := &recordingUploadClient{}
|
||||
var skipped []string
|
||||
summary, err := uploadLocalPath(context.Background(), client, root, "/backup/mac/", nil, func(localPath, _ string) {
|
||||
summary, err := uploadLocalPath(context.Background(), client, root, "/backup/mac/", uploadPathOptions{Skipped: func(localPath, _ string) {
|
||||
skipped = append(skipped, filepath.Base(localPath))
|
||||
})
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -93,7 +95,7 @@ func TestUploadLocalFileKeepsDestinationFilename(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &recordingUploadClient{}
|
||||
summary, err := uploadLocalPath(context.Background(), client, localPath, "backup/renamed.txt", nil, nil)
|
||||
summary, err := uploadLocalPath(context.Background(), client, localPath, "backup/renamed.txt", uploadPathOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -104,3 +106,117 @@ func TestUploadLocalFileKeepsDestinationFilename(t *testing.T) {
|
||||
t.Fatalf("uploaded files = %v", client.files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadLocalDirectoryHonorsExcludePatterns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := map[string]string{
|
||||
"keep.txt": "keep",
|
||||
"scratch.tmp": "temporary",
|
||||
filepath.Join(".Trash", "old"): "trash",
|
||||
filepath.Join("Library", "keep"): "library",
|
||||
filepath.Join("Library", "Caches", "cache.db"): "cache",
|
||||
}
|
||||
for name, content := range files {
|
||||
localPath := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(filepath.Dir(localPath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(localPath, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
client := &recordingUploadClient{}
|
||||
summary, err := uploadLocalPath(context.Background(), client, root, "/backup/mac", uploadPathOptions{
|
||||
Excludes: []string{".Trash", "Library/Caches/**", "*.tmp"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.Files != 2 || summary.Directories != 2 || summary.Skipped != 3 {
|
||||
t.Fatalf("summary = %+v", summary)
|
||||
}
|
||||
if want := []string{"/backup/mac", "/backup/mac/Library"}; !reflect.DeepEqual(client.directories, want) {
|
||||
t.Fatalf("directories = %q, want %q", client.directories, want)
|
||||
}
|
||||
if _, ok := client.files["/backup/mac/keep.txt"]; !ok {
|
||||
t.Fatalf("keep.txt was not uploaded: %v", client.files)
|
||||
}
|
||||
if _, ok := client.files["/backup/mac/Library/keep"]; !ok {
|
||||
t.Fatalf("Library/keep was not uploaded: %v", client.files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadLocalDirectoryContinuesAfterReadError(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "blocked"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "good.txt"), []byte("good"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entryByName := make(map[string]os.DirEntry, len(entries))
|
||||
for _, entry := range entries {
|
||||
entryByName[entry.Name()] = entry
|
||||
}
|
||||
rootInfo, err := os.Stat(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
walk := func(_ string, visit fs.WalkDirFunc) error {
|
||||
if err := visit(root, fs.FileInfoToDirEntry(rootInfo), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
blockedPath := filepath.Join(root, "blocked")
|
||||
if err := visit(blockedPath, entryByName["blocked"], fs.ErrPermission); err != nil && err != fs.SkipDir {
|
||||
return err
|
||||
}
|
||||
return visit(filepath.Join(root, "good.txt"), entryByName["good.txt"], nil)
|
||||
}
|
||||
|
||||
client := &recordingUploadClient{}
|
||||
var skipped []string
|
||||
summary, err := uploadLocalDirectory(context.Background(), client, root, "/backup/mac", uploadPathOptions{Skipped: func(localPath, reason string) {
|
||||
skipped = append(skipped, filepath.Base(localPath)+":"+reason)
|
||||
}}, walk)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.Files != 1 || summary.Skipped != 1 {
|
||||
t.Fatalf("summary = %+v", summary)
|
||||
}
|
||||
if len(skipped) != 1 || !strings.Contains(skipped[0], "blocked:无法读取: permission denied") {
|
||||
t.Fatalf("skipped = %q", skipped)
|
||||
}
|
||||
if !bytes.Equal(client.files["/backup/mac/good.txt"], []byte("good")) {
|
||||
t.Fatalf("good.txt was not uploaded: %v", client.files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadExcludePatternMatching(t *testing.T) {
|
||||
patterns, err := normalizeUploadExcludes([]string{".Trash/", "Library/Caches/**", "**/*.tmp"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, want := range map[string]bool{
|
||||
".Trash": true,
|
||||
"nested/.Trash": true,
|
||||
"Library/Caches": true,
|
||||
"Library/Caches/data/file": true,
|
||||
"root.tmp": true,
|
||||
"nested/file.tmp": true,
|
||||
"Library/Application/file": false,
|
||||
"nested/file.tmp.keep": false,
|
||||
} {
|
||||
if got := matchesUploadExclude(name, patterns); got != want {
|
||||
t.Errorf("matchesUploadExclude(%q) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
if _, err := normalizeUploadExcludes([]string{"bad["}); err == nil {
|
||||
t.Fatal("invalid exclude pattern was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user