diff --git a/hotline/file_transfer.go b/hotline/file_transfer.go index d0ce597..67f4f92 100644 --- a/hotline/file_transfer.go +++ b/hotline/file_transfer.go @@ -13,6 +13,7 @@ import ( "math" "os" "path" + "path/filepath" "slices" "strings" "sync" @@ -404,6 +405,15 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil // This notifies the server to send the next item header basePathLen := len(fullPath) + walkRoot := fullPath + "/" + if target, err := fileStore.ReadLink(fullPath); err == nil { + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(fullPath), target) + } + if targetInfo, err := fileStore.Stat(target); err == nil && targetInfo.IsDir() { + walkRoot = target + } + } rLogger.Info("Start folder download", "path", fullPath) @@ -413,25 +423,36 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil } i := 0 - err := fileStore.Walk(fullPath+"/", func(path string, info os.FileInfo, err error) error { + err := fileStore.Walk(walkRoot, func(walkPath string, info os.FileInfo, err error) error { //s.Stats.DownloadCounter += 1 i += 1 if err != nil { return err } + displayPath := walkPath + if filepath.Clean(walkRoot) != filepath.Clean(fullPath+"/") { + relativePath, err := filepath.Rel(walkRoot, walkPath) + if err != nil { + return fmt.Errorf("map symlink download path: %w", err) + } + displayPath = fullPath + string(filepath.Separator) + if relativePath != "." { + displayPath = filepath.Join(fullPath, relativePath) + } + } // skip dot files if strings.HasPrefix(info.Name(), ".") { return nil } - hlFile, err := NewFile(fileStore, path, 0) + hlFile, err := NewFile(fileStore, walkPath, 0) if err != nil { return err } - subPath := path[basePathLen+1:] + subPath := displayPath[basePathLen+1:] if i == 1 { return nil @@ -496,14 +517,18 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil return fmt.Errorf("error sending flat file object: %w", err) } - file, err := fileStore.Open(path) + file, err := fileStore.Open(walkPath) if err != nil { return fmt.Errorf("error opening file: %w", err) } if _, err = io.Copy(rwc, io.TeeReader(file, fileTransfer.bytesSentCounter)); err != nil { + _ = file.Close() return fmt.Errorf("error sending file: %w", err) } + if err := file.Close(); err != nil { + return fmt.Errorf("error closing file: %w", err) + } if nextAction[1] != 2 && hlFile.Ffo.FlatFileHeader.ForkCount[1] == 3 { err = binary.Write(rwc, binary.BigEndian, hlFile.rsrcForkHeader()) @@ -517,8 +542,12 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil } if _, err = io.Copy(rwc, io.TeeReader(rFile, fileTransfer.bytesSentCounter)); err != nil { + _ = rFile.Close() return fmt.Errorf("error sending resource fork: %w", err) } + if err := rFile.Close(); err != nil { + return fmt.Errorf("error closing resource fork: %w", err) + } } // Read the client's Next Action request. This is always 3, I think? diff --git a/hotline/file_transfer_handlers_test.go b/hotline/file_transfer_handlers_test.go index 06bc041..ce9c8a8 100644 --- a/hotline/file_transfer_handlers_test.go +++ b/hotline/file_transfer_handlers_test.go @@ -286,6 +286,19 @@ func runFolderDownloadClient(t *testing.T, conn net.Conn, nExpected int) []downl return got } +type symlinkRootFileStore struct { + *OSFileStore + linkName string + target string +} + +func (s *symlinkRootFileStore) ReadLink(name string) (string, error) { + if filepath.Clean(name) == filepath.Clean(s.linkName) { + return s.target, nil + } + return s.OSFileStore.ReadLink(name) +} + func TestDownloadFolderHandler(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) @@ -333,6 +346,35 @@ func TestDownloadFolderHandler(t *testing.T) { assert.Equal(t, []byte("bbbbb"), byName[filepath.Join("sub", "b.txt")].data) }) + t.Run("streams files from a symlinked root", func(t *testing.T) { + dir := t.TempDir() + fileStore := &symlinkRootFileStore{OSFileStore: &OSFileStore{}} + + root := filepath.Join(dir, "share") + fileStore.linkName = root + target := filepath.Join(dir, "actual-share") + fileStore.target = filepath.Base(target) // Symlink targets are often relative to their parent. + require.NoError(t, os.Mkdir(root, 0755)) + require.NoError(t, os.Mkdir(target, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(target, "a.txt"), []byte("from target"), 0644)) + + serverConn, clientConn := net.Pipe() + require.NoError(t, clientConn.SetDeadline(time.Now().Add(10*time.Second))) + + ft := &FileTransfer{bytesSentCounter: &WriteCounter{}} + errCh := make(chan error, 1) + go func() { + errCh <- DownloadFolderHandler(serverConn, root, ft, fileStore, logger, true) + _ = serverConn.Close() + }() + + got := runFolderDownloadClient(t, clientConn, 1) + require.NoError(t, <-errCh) + require.Len(t, got, 1) + assert.Equal(t, "a.txt", got[0].name) + assert.Equal(t, []byte("from target"), got[0].data) + }) + t.Run("honors a resume request for a file", func(t *testing.T) { dir := t.TempDir() fileStore := &OSFileStore{}