fix directory hardlink transfer

This commit is contained in:
ShukeBta
2026-07-02 18:38:26 +08:00
parent a1687eb746
commit 77b6e448e0
5 changed files with 129 additions and 4 deletions
+5 -4
View File
@@ -193,9 +193,6 @@ func (s *FileManagerService) Transfer(sourcePath, destDir string, mode TransferM
if err != nil {
return nil, err
}
if info.IsDir() {
return nil, errors.New("directory transfer is not supported yet")
}
dst := filepath.Join(dstDir, filepath.Base(src))
if !s.withinAllowed(dst, roots) {
return nil, ErrPathOutOfBounds
@@ -206,7 +203,11 @@ func (s *FileManagerService) Transfer(sourcePath, destDir string, mode TransferM
if mode == "" {
mode = TransferCopy
}
if err := transferFile(src, dst, mode); err != nil {
if info.IsDir() {
if err := transferDirectory(src, dst, mode); err != nil {
return nil, err
}
} else if err := transferFile(src, dst, mode); err != nil {
return nil, err
}
return &FileOperationResult{Path: dst}, nil
+65
View File
@@ -93,6 +93,58 @@ func TestFileManagerRecursiveListAndMutations(t *testing.T) {
}
}
func TestFileManagerTransferDirectoryHardlinksFiles(t *testing.T) {
root := t.TempDir()
if hardlinksUnsupported(t, root) {
t.Skip("hardlinks unsupported on this filesystem")
}
sourceDir := filepath.Join(root, "downloads", "Show")
seasonDir := filepath.Join(sourceDir, "Season 01")
targetRoot := filepath.Join(root, "media")
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(targetRoot, 0o755); err != nil {
t.Fatal(err)
}
sourceFile := filepath.Join(seasonDir, "Show.S01E01.mkv")
if err := os.WriteFile(sourceFile, []byte("episode"), 0o644); err != nil {
t.Fatal(err)
}
svc := newFileManagerTestService(t, root)
res, err := svc.Transfer(sourceDir, targetRoot, TransferHardlink)
if err != nil {
t.Fatal(err)
}
targetFile := filepath.Join(res.Path, "Season 01", "Show.S01E01.mkv")
sourceInfo, err := os.Stat(sourceFile)
if err != nil {
t.Fatal(err)
}
targetInfo, err := os.Stat(targetFile)
if err != nil {
t.Fatalf("hardlinked directory file missing: %v", err)
}
if !os.SameFile(sourceInfo, targetInfo) {
t.Fatal("directory hardlink should hardlink contained files")
}
listing, err := svc.List(res.Path, 100, true)
if err != nil {
t.Fatal(err)
}
found := false
for _, entry := range listing.Entries {
if entry.Path == targetFile {
found = true
break
}
}
if !found {
t.Fatalf("target directory listing did not include %s", targetFile)
}
}
func TestFileManagerRefusesRootMutation(t *testing.T) {
root := t.TempDir()
svc := newFileManagerTestService(t, root)
@@ -101,6 +153,19 @@ func TestFileManagerRefusesRootMutation(t *testing.T) {
}
}
func hardlinksUnsupported(t *testing.T, root string) bool {
t.Helper()
src := filepath.Join(root, "hardlink-probe-src")
dst := filepath.Join(root, "hardlink-probe-dst")
if err := os.WriteFile(src, []byte("probe"), 0o644); err != nil {
t.Fatal(err)
}
err := os.Link(src, dst)
_ = os.Remove(src)
_ = os.Remove(dst)
return err != nil
}
func TestFileManagerIncludesConfiguredOrganizeRoots(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "downloads")
+49
View File
@@ -134,3 +134,52 @@ func moveFile(src, dst string) error {
}
return os.Remove(src)
}
func transferDirectory(src, dst string, mode TransferMode) error {
if _, err := os.Stat(dst); err == nil {
return fmt.Errorf("destination already exists: %s", dst)
}
switch mode {
case TransferSymlink:
return transferFile(src, dst, mode)
case TransferMove:
if err := os.Rename(src, dst); err == nil {
return nil
}
if err := transferDirectoryTree(src, dst, TransferCopy); err != nil {
return err
}
return os.RemoveAll(src)
case TransferHardlink, TransferCopy:
return transferDirectoryTree(src, dst, mode)
default:
return transferDirectory(src, dst, TransferMove)
}
}
func transferDirectoryTree(src, dst string, mode TransferMode) error {
if err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
if rel == "." {
return os.MkdirAll(dst, 0o755) // #nosec G301 -- media folders must remain readable by local players.
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755) // #nosec G301 -- media folders must remain readable by local players.
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { // #nosec G301 -- media folders must remain readable by local players.
return err
}
return transferFile(path, target, mode)
}); err != nil {
_ = os.RemoveAll(dst)
return err
}
return nil
}