diff options
| author | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-07-10 10:07:20 -0700 |
|---|---|---|
| committer | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-07-10 10:07:20 -0700 |
| commit | c82ba6624a184e5d17b9e3857ac908f40b22a191 (patch) | |
| tree | 849992a458764c2695dd3d3383d2e81858fc8477 /hotline | |
| parent | f50aad709075c1f06bfc7285c3185382dfcdcf20 (diff) | |
Add tests for file-transfer upload/download folder handlers
Cover UploadHandler, DownloadFolderHandler, and UploadFolderHandler, which
previously had 0% coverage, along with the fork writers they exercise
(rsrcForkWriter, InfoForkWriter, incFileWriter). Tests drive the real handlers
against an OSFileStore-backed temp dir and script the client side of the
interactive folder protocols over net.Pipe, including the resume and
skip-existing branches.
Raises hotline package coverage from 67.1% to 75.3%.
Diffstat (limited to 'hotline')
| -rw-r--r-- | hotline/file_transfer_handlers_test.go | 620 |
1 files changed, 620 insertions, 0 deletions
diff --git a/hotline/file_transfer_handlers_test.go b/hotline/file_transfer_handlers_test.go new file mode 100644 index 0000000..2af95cc --- /dev/null +++ b/hotline/file_transfer_handlers_test.go @@ -0,0 +1,620 @@ +package hotline + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "log/slog" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests exercise the file-transfer handlers (UploadHandler, +// DownloadFolderHandler, UploadFolderHandler) and, transitively, the fork +// writers in file_wrapper.go. They drive the real handlers against an +// OSFileStore backed by a temp dir so that the resource/info fork sidecar +// files land on disk and can be asserted. +// +// The folder handlers speak an interactive, lock-step protocol with the +// client. Those tests run the handler in a goroutine over one half of a +// net.Pipe and script the client on the other half. + +// u32b encodes n as a big-endian [4]byte. +func u32b(n int) [4]byte { + var b [4]byte + binary.BigEndian.PutUint32(b[:], uint32(n)) + return b +} + +// readWriter adapts a reader and writer into an io.ReadWriter for the transfer +// handlers. A nil Writer discards writes (UploadHandler never writes back). +type readWriter struct { + r io.Reader + w io.Writer +} + +func (rw readWriter) Read(p []byte) (int, error) { return rw.r.Read(p) } +func (rw readWriter) Write(p []byte) (int, error) { + if rw.w == nil { + return len(p), nil + } + return rw.w.Write(p) +} + +// flatFileBytes builds the on-wire flattened file object a client sends when +// uploading a file: FFO header + info fork + data fork header + data, followed +// by the resource fork header + resource bytes when rsrc is non-nil. This +// mirrors exactly what receiveFile consumes. +func flatFileBytes(name string, data, rsrc []byte) []byte { + ffo := &flattenedFileObject{ + FlatFileHeader: FlatFileHeader{ + Format: FormatFILP, + Version: [2]byte{0, 1}, + ForkCount: [2]byte{0, 2}, + }, + FlatFileInformationFork: NewFlatFileInformationFork(name, [8]byte{}, "TEXT", "TEXT"), + FlatFileDataForkHeader: FlatFileForkHeader{ + ForkType: ForkTypeDATA, + DataSize: u32b(len(data)), + }, + } + if rsrc != nil { + ffo.FlatFileHeader.ForkCount = [2]byte{0, 3} + } + + // flattenedFileObject.Read emits everything through the data fork header + // but not the data itself or the resource fork. + b, _ := io.ReadAll(ffo) + b = append(b, data...) + + if rsrc != nil { + var hdr bytes.Buffer + _ = binary.Write(&hdr, binary.BigEndian, FlatFileForkHeader{ForkType: ForkTypeMACR, DataSize: u32b(len(rsrc))}) + b = append(b, hdr.Bytes()...) + b = append(b, rsrc...) + } + return b +} + +// writeInfoFork writes a valid .info_<name> sidecar so that NewFile reports a +// 3-fork file (ForkCount == 3), which is what gates the resource fork being +// sent during a folder download. +func writeInfoFork(t *testing.T, dir, name string) { + t.Helper() + info := NewFlatFileInformationFork(name, [8]byte{}, "TEXT", "TEXT") + b, err := io.ReadAll(&info) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, fmt.Sprintf(InfoForkNameTemplate, name)), b, 0644)) +} + +func rsrcPath(dir, name string) string { + return filepath.Join(dir, fmt.Sprintf(RsrcForkNameTemplate, name)) +} + +func TestUploadHandler(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + t.Run("uploads a new file with data and resource forks", func(t *testing.T) { + dir := t.TempDir() + fileStore := &OSFileStore{} + dst := filepath.Join(dir, "greeting.txt") + + data := []byte("the quick brown fox") + rsrc := []byte("resource-fork-bytes") + + ft := &FileTransfer{bytesSentCounter: &WriteCounter{}} + rwc := readWriter{r: bytes.NewReader(flatFileBytes("greeting.txt", data, rsrc))} + + require.NoError(t, UploadHandler(rwc, dst, ft, fileStore, logger, true)) + + // Data fork committed to the final path; the .incomplete temp file is + // renamed away. + got, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, data, got) + _, err = os.Stat(dst + IncompleteFileSuffix) + assert.True(t, os.IsNotExist(err), "incomplete file should be renamed away") + + // Resource and info fork sidecars written. + gotRsrc, err := os.ReadFile(rsrcPath(dir, "greeting.txt")) + require.NoError(t, err) + assert.Equal(t, rsrc, gotRsrc) + _, err = os.Stat(filepath.Join(dir, fmt.Sprintf(InfoForkNameTemplate, "greeting.txt"))) + require.NoError(t, err, "info fork sidecar should be written") + + // The data bytes are counted through the transfer counter. + assert.Equal(t, int64(len(data)+len(rsrc)), ft.bytesSentCounter.Total) + }) + + t.Run("uploads without preserving forks", func(t *testing.T) { + dir := t.TempDir() + fileStore := &OSFileStore{} + dst := filepath.Join(dir, "plain.txt") + data := []byte("plain data") + + ft := &FileTransfer{bytesSentCounter: &WriteCounter{}} + rwc := readWriter{r: bytes.NewReader(flatFileBytes("plain.txt", data, nil))} + + require.NoError(t, UploadHandler(rwc, dst, ft, fileStore, logger, false)) + + got, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, data, got) + + // preserveForks == false: no sidecar forks created. + _, err = os.Stat(rsrcPath(dir, "plain.txt")) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("refuses to overwrite an existing file", func(t *testing.T) { + dir := t.TempDir() + fileStore := &OSFileStore{} + dst := filepath.Join(dir, "exists.txt") + require.NoError(t, os.WriteFile(dst, []byte("original"), 0644)) + + ft := &FileTransfer{bytesSentCounter: &WriteCounter{}} + rwc := readWriter{r: bytes.NewReader(flatFileBytes("exists.txt", []byte("new data"), nil))} + + err := UploadHandler(rwc, dst, ft, fileStore, logger, true) + require.Error(t, err) + + // The existing file is left untouched. + got, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, []byte("original"), got) + }) +} + +// downloadedFile is a file a scripted folder-download client received. +type downloadedFile struct { + name string + data []byte + rsrc []byte + dir bool +} + +// readFolderDownloadHeader reads one FileHeader (Size[2] then Size bytes of +// Type[2] + FilePath) and returns the decoded relative name and whether it is a +// directory. +func readFolderDownloadHeader(t *testing.T, conn net.Conn) (name string, isDir bool) { + t.Helper() + var sizeBuf [2]byte + _, err := io.ReadFull(conn, sizeBuf[:]) + require.NoError(t, err) + body := make([]byte, binary.BigEndian.Uint16(sizeBuf[:])) + _, err = io.ReadFull(conn, body) + require.NoError(t, err) + + var fp FilePath + _, err = fp.Write(body[2:]) + require.NoError(t, err) + for _, item := range fp.Items { + name = filepath.Join(name, string(item.Name)) + } + return name, body[1] == 1 +} + +// runFolderDownloadClient scripts the client side of DownloadFolderHandler over +// conn, expecting exactly nExpected item headers, and returns what it received. +func runFolderDownloadClient(t *testing.T, conn net.Conn, nExpected int) []downloadedFile { + t.Helper() + + // The server's first read is an unused pre-walk action. + _, err := conn.Write([]byte{0, DlFldrActionSendFile}) + require.NoError(t, err) + + var got []downloadedFile + for i := 0; i < nExpected; i++ { + name, isDir := readFolderDownloadHeader(t, conn) + + if isDir { + // Tell the server to advance to the next item. + _, err = conn.Write([]byte{0, DlFldrActionNextFile}) + require.NoError(t, err) + got = append(got, downloadedFile{name: name, dir: true}) + continue + } + + // Request the file. + _, err = conn.Write([]byte{0, DlFldrActionSendFile}) + require.NoError(t, err) + + // Server sends the 4-byte transfer size, then the flattened file + // object, then the raw data fork, then (if 3 forks) the resource fork. + var xferSize [4]byte + _, err = io.ReadFull(conn, xferSize[:]) + require.NoError(t, err) + + var ffo flattenedFileObject + _, err = ffo.ReadFrom(conn) + require.NoError(t, err) + + data := make([]byte, ffo.dataSize()) + _, err = io.ReadFull(conn, data) + require.NoError(t, err) + + var rsrc []byte + if ffo.FlatFileHeader.ForkCount[1] == 3 { + var rh FlatFileForkHeader + require.NoError(t, binary.Read(conn, binary.BigEndian, &rh)) + rsrc = make([]byte, binary.BigEndian.Uint32(rh.DataSize[:])) + _, err = io.ReadFull(conn, rsrc) + require.NoError(t, err) + } + + // Tell the server to advance to the next item. + _, err = conn.Write([]byte{0, DlFldrActionNextFile}) + require.NoError(t, err) + + got = append(got, downloadedFile{name: name, data: data, rsrc: rsrc}) + } + return got +} + +func TestDownloadFolderHandler(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + t.Run("streams a folder tree including a subdir and a resource fork", func(t *testing.T) { + dir := t.TempDir() + fileStore := &OSFileStore{} + + root := filepath.Join(dir, "share") + require.NoError(t, os.Mkdir(root, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "a.txt"), []byte("aaa"), 0644)) + // Give a.txt a resource fork (info sidecar flips ForkCount to 3). + require.NoError(t, os.WriteFile(rsrcPath(root, "a.txt"), []byte("RRRR"), 0644)) + writeInfoFork(t, root, "a.txt") + require.NoError(t, os.Mkdir(filepath.Join(root, "sub"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "sub", "b.txt"), []byte("bbbbb"), 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() + }() + + // Walk visits: share, .rsrc_a.txt (dot, skipped), .info_a.txt (dot, + // skipped), a.txt, sub, sub/b.txt -> 3 headers reach the client. + got := runFolderDownloadClient(t, clientConn, 3) + require.NoError(t, <-errCh) + + byName := map[string]downloadedFile{} + for _, f := range got { + byName[f.name] = f + } + + require.Contains(t, byName, "a.txt") + assert.Equal(t, []byte("aaa"), byName["a.txt"].data) + assert.Equal(t, []byte("RRRR"), byName["a.txt"].rsrc) + + require.Contains(t, byName, "sub") + assert.True(t, byName["sub"].dir) + + require.Contains(t, byName, filepath.Join("sub", "b.txt")) + assert.Equal(t, []byte("bbbbb"), byName[filepath.Join("sub", "b.txt")].data) + }) + + t.Run("honors a resume request for a file", func(t *testing.T) { + dir := t.TempDir() + fileStore := &OSFileStore{} + + root := filepath.Join(dir, "share") + require.NoError(t, os.Mkdir(root, 0755)) + fileData := []byte("0123456789") + require.NoError(t, os.WriteFile(filepath.Join(root, "big.txt"), fileData, 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() + }() + + // Pre-walk action. + _, err := clientConn.Write([]byte{0, DlFldrActionSendFile}) + require.NoError(t, err) + + name, isDir := readFolderDownloadHeader(t, clientConn) + require.Equal(t, "big.txt", name) + require.False(t, isDir) + + // Ask to resume the file from offset 5. + _, err = clientConn.Write([]byte{0, DlFldrActionResumeFile}) + require.NoError(t, err) + + offset := u32b(5) + frd := NewFileResumeData([]ForkInfoList{*NewForkInfoList(offset[:])}) + resumeBytes, err := frd.BinaryMarshal() + require.NoError(t, err) + lenBuf := make([]byte, 2) + binary.BigEndian.PutUint16(lenBuf, uint16(len(resumeBytes))) + _, err = clientConn.Write(append(lenBuf, resumeBytes...)) + require.NoError(t, err) + + // Server replies with the transfer size, the flattened file object, and + // the data fork. + var xferSize [4]byte + _, err = io.ReadFull(clientConn, xferSize[:]) + require.NoError(t, err) + + var ffo flattenedFileObject + _, err = ffo.ReadFrom(clientConn) + require.NoError(t, err) + data := make([]byte, ffo.dataSize()) + _, err = io.ReadFull(clientConn, data) + require.NoError(t, err) + + // Advance past the (single) file so the walk completes. + _, err = clientConn.Write([]byte{0, DlFldrActionNextFile}) + require.NoError(t, err) + + require.NoError(t, <-errCh) + assert.Equal(t, fileData, data) + }) +} + +// folderUploadItem describes one item a scripted client offers during an +// UploadFolderHandler run. +type folderUploadItem struct { + relPath string // e.g. "top.txt" or "sub/inner.txt" + isDir bool + data []byte + rsrc []byte +} + +// encodeFolderUploadPath encodes relPath into the FileNamePath wire form: +// for each segment, a 2-byte separator + 1-byte length + segment bytes. +func encodeFolderUploadPath(relPath string) (pathItemCount int, encoded []byte) { + var buf bytes.Buffer + var count int + for _, seg := range strings.Split(filepath.ToSlash(relPath), "/") { + if seg == "" { + continue + } + buf.Write([]byte{0, 0}) // separator + buf.WriteByte(byte(len(seg))) // segment length + buf.WriteString(seg) // segment bytes + count++ + } + return count, buf.Bytes() +} + +// runFolderUploadClient scripts the client side of UploadFolderHandler. +func runFolderUploadClient(t *testing.T, conn net.Conn, items []folderUploadItem) { + t.Helper() + + // Server opens the flow by telling the client to send the first item. + action := make([]byte, 2) + _, err := io.ReadFull(conn, action) + require.NoError(t, err) + + for _, item := range items { + pathCount, encPath := encodeFolderUploadPath(item.relPath) + + dataSize := len(encPath) + 4 + hdr := make([]byte, 0, dataSize+2) + hdr = append(hdr, byte(dataSize>>8), byte(dataSize)) // DataSize[2] + if item.isDir { + hdr = append(hdr, 0, 1) // IsFolder + } else { + hdr = append(hdr, 0, 0) + } + hdr = append(hdr, byte(pathCount>>8), byte(pathCount)) // PathItemCount[2] + hdr = append(hdr, encPath...) + _, err := conn.Write(hdr) + require.NoError(t, err) + + // Read the server's next-action response. + _, err = io.ReadFull(conn, action) + require.NoError(t, err) + + if item.isDir { + continue // server Mkdir'd and told us to advance + } + + switch action[1] { + case DlFldrActionNextFile: + // Server already has this file; nothing more to send. + continue + case DlFldrActionSendFile: + base := filepath.Base(item.relPath) + size := u32b(len(item.data)) + _, err = conn.Write(size[:]) + require.NoError(t, err) + _, err = conn.Write(flatFileBytes(base, item.data, item.rsrc)) + require.NoError(t, err) + + // Server tells us to advance to the next item. + _, err = io.ReadFull(conn, action) + require.NoError(t, err) + default: + t.Fatalf("unexpected server action %v", action) + } + } +} + +func TestUploadFolderHandler(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + t.Run("receives a folder tree with a nested file and resource fork", func(t *testing.T) { + dir := t.TempDir() + fileStore := &OSFileStore{} + dst := filepath.Join(dir, "incoming") + + items := []folderUploadItem{ + {relPath: "top.txt", data: []byte("TOP"), rsrc: []byte("TR")}, + {relPath: "sub", isDir: true}, + {relPath: filepath.Join("sub", "inner.txt"), data: []byte("INNER")}, + } + + ft := &FileTransfer{ + bytesSentCounter: &WriteCounter{}, + FolderItemCount: []byte{0, byte(len(items))}, + } + + serverConn, clientConn := net.Pipe() + require.NoError(t, clientConn.SetDeadline(time.Now().Add(10*time.Second))) + + errCh := make(chan error, 1) + go func() { + errCh <- UploadFolderHandler(serverConn, dst, ft, fileStore, logger, true) + _ = serverConn.Close() + }() + + runFolderUploadClient(t, clientConn, items) + require.NoError(t, <-errCh) + + top, err := os.ReadFile(filepath.Join(dst, "top.txt")) + require.NoError(t, err) + assert.Equal(t, []byte("TOP"), top) + + gotRsrc, err := os.ReadFile(rsrcPath(dst, "top.txt")) + require.NoError(t, err) + assert.Equal(t, []byte("TR"), gotRsrc) + + subInfo, err := os.Stat(filepath.Join(dst, "sub")) + require.NoError(t, err) + assert.True(t, subInfo.IsDir()) + + inner, err := os.ReadFile(filepath.Join(dst, "sub", "inner.txt")) + require.NoError(t, err) + assert.Equal(t, []byte("INNER"), inner) + }) + + t.Run("skips a file that already exists", func(t *testing.T) { + dir := t.TempDir() + fileStore := &OSFileStore{} + dst := filepath.Join(dir, "incoming") + require.NoError(t, os.Mkdir(dst, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dst, "dup.txt"), []byte("keep me"), 0644)) + + items := []folderUploadItem{ + {relPath: "dup.txt", data: []byte("should be ignored")}, + } + + ft := &FileTransfer{ + bytesSentCounter: &WriteCounter{}, + FolderItemCount: []byte{0, byte(len(items))}, + } + + serverConn, clientConn := net.Pipe() + require.NoError(t, clientConn.SetDeadline(time.Now().Add(10*time.Second))) + + errCh := make(chan error, 1) + go func() { + errCh <- UploadFolderHandler(serverConn, dst, ft, fileStore, logger, true) + _ = serverConn.Close() + }() + + runFolderUploadClient(t, clientConn, items) + require.NoError(t, <-errCh) + + // The pre-existing file is left untouched. + got, err := os.ReadFile(filepath.Join(dst, "dup.txt")) + require.NoError(t, err) + assert.Equal(t, []byte("keep me"), got) + }) + + t.Run("resumes a partially uploaded file", func(t *testing.T) { + dir := t.TempDir() + fileStore := &OSFileStore{} + dst := filepath.Join(dir, "incoming") + require.NoError(t, os.Mkdir(dst, 0755)) + // A prior, interrupted upload left a partial .incomplete file. + require.NoError(t, os.WriteFile(filepath.Join(dst, "resume.txt"+IncompleteFileSuffix), []byte("012"), 0644)) + + ft := &FileTransfer{ + bytesSentCounter: &WriteCounter{}, + FolderItemCount: []byte{0, 1}, + } + + serverConn, clientConn := net.Pipe() + require.NoError(t, clientConn.SetDeadline(time.Now().Add(10*time.Second))) + + errCh := make(chan error, 1) + go func() { + errCh <- UploadFolderHandler(serverConn, dst, ft, fileStore, logger, true) + _ = serverConn.Close() + }() + + action := make([]byte, 2) + _, err := io.ReadFull(clientConn, action) // initial "send next" from server + require.NoError(t, err) + + // Offer the file the server already has partially. + pathCount, encPath := encodeFolderUploadPath("resume.txt") + dataSize := len(encPath) + 4 + hdr := []byte{byte(dataSize >> 8), byte(dataSize), 0, 0, byte(pathCount >> 8), byte(pathCount)} + hdr = append(hdr, encPath...) + _, err = clientConn.Write(hdr) + require.NoError(t, err) + + // Server detects the partial file and asks to resume. + _, err = io.ReadFull(clientConn, action) + require.NoError(t, err) + require.Equal(t, byte(DlFldrActionResumeFile), action[1]) + + // Server sends resume data (2-byte length prefix + payload); consume it. + resumeLen := make([]byte, 2) + _, err = io.ReadFull(clientConn, resumeLen) + require.NoError(t, err) + resumeData := make([]byte, binary.BigEndian.Uint16(resumeLen)) + _, err = io.ReadFull(clientConn, resumeData) + require.NoError(t, err) + + // Send the remaining data fork; it is appended to the partial file. + size := u32b(3) + _, err = clientConn.Write(size[:]) + require.NoError(t, err) + _, err = clientConn.Write(flatFileBytes("resume.txt", []byte("345"), nil)) + require.NoError(t, err) + + // Server tells us to advance, completing the run. + _, err = io.ReadFull(clientConn, action) + require.NoError(t, err) + + require.NoError(t, <-errCh) + + got, err := os.ReadFile(filepath.Join(dst, "resume.txt")) + require.NoError(t, err) + assert.Equal(t, []byte("012345"), got) + }) +} + +func TestFileTransfer_ItemCount(t *testing.T) { + ft := &FileTransfer{FolderItemCount: []byte{0x01, 0x00}} + assert.Equal(t, 256, ft.ItemCount()) +} + +func TestMemFileTransferMgr_Lifecycle(t *testing.T) { + cc := &ClientConn{ClientFileTransferMgr: NewClientFileTransferMgr()} + cc.Server = &Server{FileTransferMgr: NewMemFileTransferMgr()} + + ft := cc.NewFileTransfer(FileDownload, "/root", []byte("f"), []byte("p"), []byte{0, 0, 0, 1}) + require.NotNil(t, ft) + + // Add assigns a random reference number and registers the transfer. + got := cc.Server.FileTransferMgr.Get(ft.RefNum) + require.NotNil(t, got) + assert.Equal(t, ft, got) + assert.Len(t, cc.ClientFileTransferMgr.Get(FileDownload), 1) + + cc.Server.FileTransferMgr.Delete(ft.RefNum) + assert.Nil(t, cc.Server.FileTransferMgr.Get(ft.RefNum)) + assert.Empty(t, cc.ClientFileTransferMgr.Get(FileDownload)) +} |