diff options
| -rw-r--r-- | hotline/field.go | 14 | ||||
| -rw-r--r-- | hotline/field_test.go | 16 | ||||
| -rw-r--r-- | internal/mobius/transaction_handlers.go | 224 | ||||
| -rw-r--r-- | internal/mobius/transaction_handlers_test.go | 144 |
4 files changed, 317 insertions, 81 deletions
diff --git a/hotline/field.go b/hotline/field.go index 554f63d..99d0d35 100644 --- a/hotline/field.go +++ b/hotline/field.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/binary" "errors" + "fmt" "slices" ) @@ -137,19 +138,28 @@ func (f *Field) DecodeNewsPath() ([]string, error) { if len(f.Data) == 0 { return []string{}, nil } + if len(f.Data) < 2 { + return nil, fmt.Errorf("news path too short: %d bytes", len(f.Data)) + } pathCount := binary.BigEndian.Uint16(f.Data[0:2]) scanner := bufio.NewScanner(bytes.NewReader(f.Data[2:])) scanner.Split(newsPathScanner) - var paths []string + paths := make([]string, 0, pathCount) for i := uint16(0); i < pathCount; i++ { - scanner.Scan() + if !scanner.Scan() { + return nil, fmt.Errorf("news path truncated: declared %d items, found %d", pathCount, i) + } paths = append(paths, scanner.Text()) } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan news path: %w", err) + } + return paths, nil } diff --git a/hotline/field_test.go b/hotline/field_test.go index 6dff102..b8d909b 100644 --- a/hotline/field_test.go +++ b/hotline/field_test.go @@ -248,6 +248,22 @@ func TestField_DecodeNewsPath(t *testing.T) { wantErr: assert.NoError, }, { + name: "one byte of data returns an error instead of panicking", + fields: fields{Data: []byte{0x00}}, + want: nil, + wantErr: assert.Error, + }, + { + name: "declared path count exceeding available items returns an error", + fields: fields{Data: []byte{ + 0x00, 0x02, // path count = 2 + 0x00, 0x00, 0x05, // 2 bytes unused + 1 byte length (5) + 0x48, 0x65, 0x6c, 0x6c, 0x6f, // "Hello" (only 1 item present) + }}, + want: nil, + wantErr: assert.Error, + }, + { name: "single path", fields: fields{Data: []byte{ 0x00, 0x01, // path count = 1 diff --git a/internal/mobius/transaction_handlers.go b/internal/mobius/transaction_handlers.go index ce31abd..6458bc8 100644 --- a/internal/mobius/transaction_handlers.go +++ b/internal/mobius/transaction_handlers.go @@ -83,15 +83,33 @@ const ( ErrMsgPermanentBan = "You are permanently banned on this server" // General error messages + ErrMsgFileNotFound = "File not found." + ErrMsgGetFileInfo = "Error getting file information." + ErrMsgSetFileInfo = "Error setting file information." + ErrMsgRenameFile = "Error renaming file." + ErrMsgRenameFolder = "Error renaming folder." + ErrMsgDeleteFile = "Error deleting file." + ErrMsgMoveFile = "Error moving file." + ErrMsgCreateFolder = "Error creating folder." + ErrMsgDownloadFolder = "Error downloading folder." + ErrMsgUploadFile = "Error uploading file." + ErrMsgUploadFolder = "Error uploading folder." + ErrMsgFileResumeData = "Invalid file resume data." ErrMsgAccountNotFound = "Account not found." ErrMsgUserNotFound = "User not found." ErrMsgCreateAlias = "Error creating alias" ErrMsgUpdateAccount = "Error updating account." + ErrMsgDeleteAccount = "Error deleting account." + ErrMsgGetUserList = "Error getting user list." + ErrMsgJoinChat = "Error joining chat." ErrMsgReadNewsCategories = "Error reading news categories." + ErrMsgReadNewsArticles = "Error reading news articles." ErrMsgCreateNewsCategory = "Error creating news category." ErrMsgCreateNewsFolder = "Error creating news folder." ErrMsgDeleteNewsArticle = "Error deleting news article." + ErrMsgDeleteNewsItem = "Error deleting news item." ErrMsgPostNewsArticle = "Error posting news article." + ErrMsgPostNews = "Error posting news." ErrMsgReadMessageBoard = "Error reading message board." ) @@ -239,6 +257,8 @@ func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res [ otherClient := cc.Server.ClientMgr.Get([2]byte(userID.Data)) if otherClient == nil { + // Target user is no longer connected. The protocol defines no reply for + // this transaction, so there is nothing to send back. return res } @@ -298,17 +318,20 @@ func HandleGetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) if err != nil { - return res + cc.Logger.Error("get file info: read path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } fw, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0) if err != nil { - return res + cc.Logger.Error("get file info: open file", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } encodedName, err := cc.TextEncoder().String(fw.Name) if err != nil { - return res + cc.Logger.Error("get file info: encode name", "err", err) + return cc.NewErrReply(t, ErrMsgGetFileInfo) } fields := []hotline.Field{ @@ -350,17 +373,20 @@ func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) if err != nil { - return res + cc.Logger.Error("set file info: read path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } fi, err := cc.Server.FS.Stat(fullFilePath) if err != nil { - return res + cc.Logger.Error("set file info: stat file", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0) if err != nil { - return res + cc.Logger.Error("set file info: open file", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } if t.GetField(hotline.FieldFileComment).Data != nil { switch mode := fi.Mode(); { @@ -375,21 +401,25 @@ func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho } if err := hlFile.Ffo.FlatFileInformationFork.SetComment(t.GetField(hotline.FieldFileComment).Data); err != nil { - return res + cc.Logger.Error("set file info: set comment", "err", err) + return cc.NewErrReply(t, ErrMsgSetFileInfo) } w, err := hlFile.InfoForkWriter() if err != nil { - return res + cc.Logger.Error("set file info: open info fork writer", "err", err) + return cc.NewErrReply(t, ErrMsgSetFileInfo) } _, err = io.Copy(w, &hlFile.Ffo.FlatFileInformationFork) if err != nil { - return res + cc.Logger.Error("set file info: write info fork", "err", err) + return cc.NewErrReply(t, ErrMsgSetFileInfo) } } fullNewFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, t.GetField(hotline.FieldFileNewName).Data, cc.TextDecoder()) if err != nil { - return nil + cc.Logger.Error("set file info: read new name path", "err", err) + return cc.NewErrReply(t, ErrMsgRenameFile) } fileNewName := t.GetField(hotline.FieldFileNewName).Data @@ -403,7 +433,10 @@ func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho err = os.Rename(fullFilePath, fullNewFilePath) if os.IsNotExist(err) { return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotRenameFolderNotFound, string(fileName))) - + } + if err != nil { + cc.Logger.Error("set file info: rename folder", "err", err) + return cc.NewErrReply(t, ErrMsgRenameFolder) } case mode.IsRegular(): if !cc.Authorize(hotline.AccessRenameFile) { @@ -411,11 +444,13 @@ func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho } fileDir, err := hotline.ReadPath(cc.FileRoot(), filePath, []byte{}, cc.TextDecoder()) if err != nil { - return nil + cc.Logger.Error("set file info: read file dir", "err", err) + return cc.NewErrReply(t, ErrMsgRenameFile) } hlFile.Name, err = cc.TextDecoder().String(string(fileNewName)) if err != nil { - return res + cc.Logger.Error("set file info: decode new name", "err", err) + return cc.NewErrReply(t, ErrMsgRenameFile) } err = hlFile.Move(fileDir) @@ -423,7 +458,8 @@ func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotRenameFileNotFound, string(fileName))) } if err != nil { - return res + cc.Logger.Error("set file info: rename file", "err", err) + return cc.NewErrReply(t, ErrMsgRenameFile) } } } @@ -447,12 +483,14 @@ func HandleDeleteFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) if err != nil { - return res + cc.Logger.Error("delete file: read path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0) if err != nil { - return res + cc.Logger.Error("delete file: open file", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } fi, err := hlFile.DataFile() @@ -472,7 +510,8 @@ func HandleDeleteFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot } if err := hlFile.Delete(); err != nil { - return res + cc.Logger.Error("delete file: delete", "err", err) + return cc.NewErrReply(t, ErrMsgDeleteFile) } res = append(res, cc.NewReply(t)) @@ -492,19 +531,22 @@ func HandleMoveFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli filePath, err := hotline.ReadPath(cc.FileRoot(), t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data, cc.TextDecoder()) if err != nil { - return res + cc.Logger.Error("move file: read source path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } fileNewPath, err := hotline.ReadPath(cc.FileRoot(), t.GetField(hotline.FieldFileNewPath).Data, nil, cc.TextDecoder()) if err != nil { - return res + cc.Logger.Error("move file: read destination path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } cc.Logger.Info("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName) hlFile, err := hotline.NewFile(cc.Server.FS, filePath, 0) if err != nil { - return res + cc.Logger.Error("move file: open file", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } fi, err := hlFile.DataFile() @@ -522,7 +564,8 @@ func HandleMoveFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli } } if err := hlFile.Move(fileNewPath); err != nil { - return res + cc.Logger.Error("move file: move", "err", err) + return cc.NewErrReply(t, ErrMsgMoveFile) } // TODO: handle other possible errors; e.g. file delete fails due to permission issue @@ -554,7 +597,8 @@ func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl var newFp hotline.FilePath _, err := newFp.Write(t.GetField(hotline.FieldFilePath).Data) if err != nil { - return res + cc.Logger.Error("new folder: parse file path", "err", err) + return cc.NewErrReply(t, ErrMsgCreateFolder) } for _, pathItem := range newFp.Items { @@ -566,11 +610,13 @@ func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl // The FileRoot is already a UTF-8 filesystem path and must not be decoded. subPath, err := cc.TextDecoder().String(subPath) if err != nil { - return res + cc.Logger.Error("new folder: decode sub path", "err", err) + return cc.NewErrReply(t, ErrMsgCreateFolder) } folderName, err = cc.TextDecoder().String(folderName) if err != nil { - return res + cc.Logger.Error("new folder: decode folder name", "err", err) + return cc.NewErrReply(t, ErrMsgCreateFolder) } newFolderPath := path.Join(cc.FileRoot(), subPath, folderName) @@ -750,7 +796,8 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot var field hotline.Field if _, err := field.Write(scanner.Bytes()); err != nil { - return res + cc.Logger.Error("update user: parse sub-field", "err", err) + return cc.NewErrReply(t, ErrMsgUpdateAccount) } subFields = append(subFields, field) } @@ -767,7 +814,7 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot if err := cc.Server.AccountManager.Delete(login); err != nil { cc.Logger.Error("Error deleting account", "Err", err) - return res + return cc.NewErrReply(t, ErrMsgDeleteAccount) } for _, client := range cc.Server.ClientMgr.List() { @@ -844,7 +891,8 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot err := cc.Server.AccountManager.Update(*acc, string(hotline.EncodeString(hotline.GetField(hotline.FieldUserLogin, &subFields).Data))) if err != nil { - return res + cc.Logger.Error("update user: update account", "err", err) + return cc.NewErrReply(t, ErrMsgUpdateAccount) } } else { if !cc.Authorize(hotline.AccessCreateUser) { @@ -940,7 +988,7 @@ func HandleDeleteUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot if err := cc.Server.AccountManager.Delete(login); err != nil { cc.Logger.Error("Error deleting account", "Err", err) - return res + return cc.NewErrReply(t, ErrMsgDeleteAccount) } for _, client := range cc.Server.ClientMgr.List() { @@ -1028,7 +1076,8 @@ func HandleGetUserNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res Name: string(c.UserName), }) if err != nil { - return nil + cc.Logger.Error("get user name list: read user info", "err", err) + return cc.NewErrReply(t, ErrMsgGetUserList) } fields = append(fields, hotline.NewField(hotline.FieldUsernameWithInfo, b)) @@ -1076,6 +1125,7 @@ func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot cc.Logger.Error("Failed to ban IP for banned nickname", "ip", ip, "err", err) } cc.Disconnect() + // Connection is being torn down; no reply is sent to the client. return res } @@ -1151,7 +1201,7 @@ func HandleTranOldPostNews(cc *hotline.ClientConn, t *hotline.Transaction) (res _, err := cc.Server.MessageBoard.Write([]byte(newsPost)) if err != nil { cc.Logger.Error("error writing news post", "err", err) - return nil + return cc.NewErrReply(t, ErrMsgPostNews) } // Notify all clients of updated news @@ -1252,7 +1302,7 @@ func HandleGetNewsCatNameList(cc *hotline.ClientConn, t *hotline.Transaction) (r pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() if err != nil { cc.Logger.Error("get news path", "err", err) - return nil + return cc.NewErrReply(t, ErrMsgReadNewsCategories) } var fields []hotline.Field @@ -1286,7 +1336,8 @@ func HandleNewNewsCat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot name := string(t.GetField(hotline.FieldNewsCatName).Data) pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() if err != nil { - return res + cc.Logger.Error("new news category: decode news path", "err", err) + return cc.NewErrReply(t, ErrMsgCreateNewsCategory) } err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, hotline.NewsCategory) @@ -1315,7 +1366,8 @@ func HandleNewNewsFldr(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho name := string(t.GetField(hotline.FieldFileName).Data) pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() if err != nil { - return res + cc.Logger.Error("new news folder: decode news path", "err", err) + return cc.NewErrReply(t, ErrMsgCreateNewsFolder) } err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, hotline.NewsBundle) @@ -1341,17 +1393,20 @@ func HandleGetNewsArtNameList(cc *hotline.ClientConn, t *hotline.Transaction) (r pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() if err != nil { - return res + cc.Logger.Error("get news article list: decode news path", "err", err) + return cc.NewErrReply(t, ErrMsgReadNewsArticles) } nald, err := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs) if err != nil { - return res + cc.Logger.Error("get news article list: list articles", "err", err) + return cc.NewErrReply(t, ErrMsgReadNewsArticles) } b, err := io.ReadAll(&nald) if err != nil { - return res + cc.Logger.Error("get news article list: read article list data", "err", err) + return cc.NewErrReply(t, ErrMsgReadNewsArticles) } return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldNewsArtListData, b))) @@ -1383,12 +1438,14 @@ func HandleGetNewsArtData(cc *hotline.ClientConn, t *hotline.Transaction) (res [ newsPath, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() if err != nil { - return res + cc.Logger.Error("get news article: decode news path", "err", err) + return cc.NewErrReply(t, ErrMsgReadNewsArticles) } convertedID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt() if err != nil { - return res + cc.Logger.Error("get news article: decode article ID", "err", err) + return cc.NewErrReply(t, ErrMsgReadNewsArticles) } art := cc.Server.ThreadedNewsMgr.GetArticle(newsPath, uint32(convertedID)) @@ -1421,8 +1478,8 @@ func HandleGetNewsArtData(cc *hotline.ClientConn, t *hotline.Transaction) (res [ func HandleDelNewsItem(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() if err != nil || len(pathStrs) == 0 { - cc.Logger.Error("invalid news path") - return nil + cc.Logger.Error("delete news item: invalid news path", "err", err) + return cc.NewErrReply(t, ErrMsgDeleteNewsItem) } item := cc.Server.ThreadedNewsMgr.NewsItem(pathStrs) @@ -1439,7 +1496,8 @@ func HandleDelNewsItem(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho err = cc.Server.ThreadedNewsMgr.DeleteNewsItem(pathStrs) if err != nil { - return res + cc.Logger.Error("delete news item", "err", err) + return cc.NewErrReply(t, ErrMsgDeleteNewsItem) } return append(res, cc.NewReply(t)) @@ -1463,13 +1521,14 @@ func HandleDelNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() if err != nil { - return res + cc.Logger.Error("delete news article: decode news path", "err", err) + return cc.NewErrReply(t, ErrMsgDeleteNewsArticle) } articleID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt() if err != nil { - cc.Logger.Error("error reading article Type", "err", err) - return + cc.Logger.Error("delete news article: decode article ID", "err", err) + return cc.NewErrReply(t, ErrMsgDeleteNewsArticle) } deleteRecursive := bytes.Equal([]byte{0, 1}, t.GetField(hotline.FieldNewsArtRecurseDel).Data) @@ -1503,13 +1562,14 @@ func HandlePostNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() if err != nil || len(pathStrs) == 0 { - cc.Logger.Error("invalid news path") - return res + cc.Logger.Error("post news article: invalid news path", "err", err) + return cc.NewErrReply(t, ErrMsgPostNewsArticle) } parentArticleID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt() if err != nil { - return res + cc.Logger.Error("post news article: decode parent article ID", "err", err) + return cc.NewErrReply(t, ErrMsgPostNewsArticle) } err = cc.Server.ThreadedNewsMgr.PostArticle( @@ -1581,7 +1641,8 @@ func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []h var frd hotline.FileResumeData if resumeData != nil { if err := frd.UnmarshalBinary(t.GetField(hotline.FieldFileResumeData).Data); err != nil { - return res + cc.Logger.Error("download file: unmarshal resume data", "err", err) + return cc.NewErrReply(t, ErrMsgFileResumeData) } // TODO: handle rsrc fork offset dataOffset = int64(binary.BigEndian.Uint32(frd.ForkInfoList[0].DataSize[:])) @@ -1589,12 +1650,14 @@ func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []h fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) if err != nil { - return res + cc.Logger.Error("download file: read path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, dataOffset) if err != nil { - return res + cc.Logger.Error("download file: open file", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } xferSize := hlFile.Ffo.TransferSize(0) @@ -1610,7 +1673,8 @@ func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []h if resumeData != nil { var frd hotline.FileResumeData if err := frd.UnmarshalBinary(t.GetField(hotline.FieldFileResumeData).Data); err != nil { - return res + cc.Logger.Error("download file: unmarshal resume data", "err", err) + return cc.NewErrReply(t, ErrMsgFileResumeData) } ft.FileResumeData = &frd } @@ -1653,16 +1717,19 @@ func HandleDownloadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res [ fullFilePath, err := hotline.ReadPath(cc.FileRoot(), t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data, cc.TextDecoder()) if err != nil { - return nil + cc.Logger.Error("download folder: read path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } transferSize, err := hotline.CalcTotalSize(fullFilePath) if err != nil { - return nil + cc.Logger.Error("download folder: calc total size", "err", err) + return cc.NewErrReply(t, ErrMsgDownloadFolder) } itemCount, err := hotline.CalcItemCount(fullFilePath) if err != nil { - return nil + cc.Logger.Error("download folder: calc item count", "err", err) + return cc.NewErrReply(t, ErrMsgDownloadFolder) } fileTransfer := cc.NewFileTransfer(hotline.FolderDownload, cc.FileRoot(), t.GetField(hotline.FieldFileName).Data, t.GetField(hotline.FieldFilePath).Data, transferSize) @@ -1670,7 +1737,8 @@ func HandleDownloadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res [ var fp hotline.FilePath _, err = fp.Write(t.GetField(hotline.FieldFilePath).Data) if err != nil { - return nil + cc.Logger.Error("download folder: parse file path", "err", err) + return cc.NewErrReply(t, ErrMsgDownloadFolder) } res = append(res, cc.NewReply(t, @@ -1703,7 +1771,8 @@ func HandleUploadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []h var fp hotline.FilePath if t.GetField(hotline.FieldFilePath).Data != nil { if _, err := fp.Write(t.GetField(hotline.FieldFilePath).Data); err != nil { - return res + cc.Logger.Error("upload folder: parse file path", "err", err) + return cc.NewErrReply(t, ErrMsgUploadFolder) } } @@ -1752,7 +1821,8 @@ func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot var fp hotline.FilePath if filePath != nil { if _, err := fp.Write(filePath); err != nil { - return res + cc.Logger.Error("upload file: parse file path", "err", err) + return cc.NewErrReply(t, ErrMsgUploadFile) } } @@ -1764,7 +1834,8 @@ func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot } fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) if err != nil { - return res + cc.Logger.Error("upload file: read path", "err", err) + return cc.NewErrReply(t, ErrMsgUploadFile) } if _, err := cc.Server.FS.Stat(fullFilePath); err == nil { @@ -1777,23 +1848,24 @@ func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot // client has requested to resume a partially transferred file if transferOptions != nil { - fileInfo, err := cc.Server.FS.Stat(fullFilePath + hotline.IncompleteFileSuffix) - if err != nil { - return res - } - - offset := make([]byte, 4) - binary.BigEndian.PutUint32(offset, uint32(fileInfo.Size())) + // If there is no partial file to resume from, fall back to a normal upload + // reply (with the reference number already set) rather than discarding it. + if fileInfo, err := cc.Server.FS.Stat(fullFilePath + hotline.IncompleteFileSuffix); err != nil { + cc.Logger.Info("upload file: no partial file to resume, starting fresh upload", "err", err) + } else { + offset := make([]byte, 4) + binary.BigEndian.PutUint32(offset, uint32(fileInfo.Size())) - fileResumeData := hotline.NewFileResumeData([]hotline.ForkInfoList{ - *hotline.NewForkInfoList(offset), - }) + fileResumeData := hotline.NewFileResumeData([]hotline.ForkInfoList{ + *hotline.NewForkInfoList(offset), + }) - b, _ := fileResumeData.BinaryMarshal() + b, _ := fileResumeData.BinaryMarshal() - ft.TransferSize = offset + ft.TransferSize = offset - replyT.Fields = append(replyT.Fields, hotline.NewField(hotline.FieldFileResumeData, b)) + replyT.Fields = append(replyT.Fields, hotline.NewField(hotline.FieldFileResumeData, b)) + } } res = append(res, replyT) @@ -1847,6 +1919,7 @@ func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (re cc.Logger.Error("Failed to ban IP for banned nickname", "ip", ip, "err", err) } cc.Disconnect() + // Connection is being torn down; no reply is sent to the client. return res } } @@ -2104,7 +2177,8 @@ func HandleJoinChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli Name: string(c.UserName), }) if err != nil { - return res + cc.Logger.Error("join chat: read member info", "err", err) + return cc.NewErrReply(t, ErrMsgJoinChat) } replyFields = append(replyFields, hotline.NewField(hotline.FieldUsernameWithInfo, b)) } @@ -2185,12 +2259,14 @@ func HandleMakeAlias(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) if err != nil { - return res + cc.Logger.Error("make alias: read source path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } fullNewFilePath, err := hotline.ReadPath(cc.FileRoot(), fileNewPath, fileName, cc.TextDecoder()) if err != nil { - return res + cc.Logger.Error("make alias: read destination path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) } if err := cc.Server.FS.Symlink(fullFilePath, fullNewFilePath); err != nil { diff --git a/internal/mobius/transaction_handlers_test.go b/internal/mobius/transaction_handlers_test.go index 3af374e..644bdaa 100644 --- a/internal/mobius/transaction_handlers_test.go +++ b/internal/mobius/transaction_handlers_test.go @@ -848,6 +848,44 @@ func TestHandleGetFileInfo(t *testing.T) { }, }, }, + { + name: "returns an error reply when the file path cannot be parsed", + args: args{ + cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, + Account: &hotline.Account{}, + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + FS: &hotline.OSFileStore{}, + Config: hotline.Config{ + FileRoot: func() string { + path, _ := os.Getwd() + return filepath.Join(path, "/test/config/Files") + }(), + }, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetFileInfo, [2]byte{}, + hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")), + // Malformed path: claims 1 item but supplies fewer than the + // 3 bytes a path item requires, so ReadPath fails to parse it. + hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x01, 0x00, 0x00}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgFileNotFound)), + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -855,8 +893,10 @@ func TestHandleGetFileInfo(t *testing.T) { // Clear the file timestamp fields to work around problems running the tests in multiple timezones // TODO: revisit how to test this by mocking the stat calls - gotRes[0].Fields[4].Data = make([]byte, 8) - gotRes[0].Fields[5].Data = make([]byte, 8) + if len(gotRes) > 0 && gotRes[0].ErrorCode == [4]byte{} { + gotRes[0].Fields[4].Data = make([]byte, 8) + gotRes[0].Fields[5].Data = make([]byte, 8) + } if !TranAssertEqual(t, tt.wantRes, gotRes) { t.Errorf("HandleGetFileInfo() gotRes = %v, want %v", gotRes, tt.wantRes) @@ -994,7 +1034,8 @@ func TestHandleNewFolder(t *testing.T) { return bits }(), }, - ID: [2]byte{0, 1}, + ID: [2]byte{0, 1}, + Logger: NewTestLogger(), Server: &hotline.Server{ TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder(), @@ -1017,7 +1058,16 @@ func TestHandleNewFolder(t *testing.T) { }), ), }, - wantRes: []hotline.Transaction{}, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgCreateFolder)), + }, + }, + }, }, { name: "FieldFileName does not allow directory traversal", @@ -1209,6 +1259,54 @@ func TestHandleUploadFile(t *testing.T) { } } +// When a client requests to resume an upload but no partial (.incomplete) file +// exists, the handler should fall back to a normal upload reply (reference number, +// no resume-data field) rather than discarding the reply and returning nothing. +func TestHandleUploadFile_resumeFallbackWhenNoPartialFile(t *testing.T) { + cc := &hotline.ClientConn{ + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + FS: &hotline.OSFileStore{}, + FileTransferMgr: hotline.NewMemFileTransferMgr(), + Config: hotline.Config{ + FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), + }, + }, + ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessUploadFile) + bits.Set(hotline.AccessUploadAnywhere) + return bits + }(), + }, + } + tr := hotline.NewTransaction( + hotline.TranUploadFile, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("doesNotExistYet")), + hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x01, 0x00, 0x00, 0x03, 0x2e, 0x2e, 0x2f}), + hotline.NewField(hotline.FieldFileTransferOptions, []byte{0x00, 0x02}), // request resume + ) + + gotRes := HandleUploadFile(cc, &tr) + + if len(gotRes) != 1 { + t.Fatalf("expected 1 reply transaction, got %d", len(gotRes)) + } + if gotRes[0].ErrorCode != [4]byte{} { + t.Errorf("expected a non-error reply, got error code %v", gotRes[0].ErrorCode) + } + if gotRes[0].GetField(hotline.FieldRefNum).Data == nil { + t.Errorf("expected a reference number field in the reply") + } + if gotRes[0].GetField(hotline.FieldFileResumeData).Data != nil { + t.Errorf("expected no resume data field when there is no partial file to resume") + } +} + func TestHandleMakeAlias(t *testing.T) { type args struct { cc *hotline.ClientConn @@ -2507,7 +2605,15 @@ func TestHandleUpdateUser(t *testing.T) { }), ), }, - wantRes: nil, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgDeleteAccount)), + }, + }, + }, }, { name: "when action is modify existing user with password", @@ -4112,6 +4218,34 @@ func TestHandleSetClientUserInfo(t *testing.T) { } } +// When the news path is empty, the handler should return an error reply rather +// than silently discarding the request. +func TestHandleDelNewsItem_emptyPathReturnsError(t *testing.T) { + cc := &hotline.ClientConn{ + ID: [2]byte{0, 1}, + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + }, + } + tr := hotline.NewTransaction(hotline.TranDelNewsItem, [2]byte{}) + + gotRes := HandleDelNewsItem(cc, &tr) + + wantRes := []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgDeleteNewsItem)), + }, + }, + } + TranAssertEqual(t, wantRes, gotRes) +} + func TestHandleDelNewsItem(t *testing.T) { type args struct { cc *hotline.ClientConn |