aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2026-05-30 16:16:32 -0700
committerJeff Halter <868228+jhalter@users.noreply.github.com>2026-05-30 16:16:32 -0700
commit588dce918aeda0efc4db80b30525d28943c029cd (patch)
treec13a6b60e9a550f2b4b8aeb7ff3073a3ed6d6795
parent21cf5016d98ff568692e91751d021facbdfc6bb6 (diff)
Surface transaction handler errors instead of silently discarding them
Many handlers in internal/mobius/transaction_handlers.go returned an empty transaction slice on error (return res / return nil), so the client received no reply and the operation silently no-opped, with no log in most cases. Convert these silent discards across the file to a consistent convention: log the underlying cause via cc.Logger.Error and return cc.NewErrReply with a user-facing message. Covers the file-operation, file-transfer, news, user/account, and chat handler groups. Genuinely intentional no-reply paths (target user offline, banned-nickname disconnect) are kept silent but now carry an explanatory comment. Also fixes adjacent defects found along the way: - HandleSetFileInfo: a non-IsNotExist error from os.Rename during a directory rename was swallowed; it now returns an error reply. - HandleUploadFile: when a resume is requested but no .incomplete file exists, fall back to a normal upload reply instead of discarding the reply. - hotline.DecodeNewsPath: previously panicked on a 1-byte client-supplied field (slice out of range) and silently produced empty path components on truncated input. It now validates length and framing and returns an error, which the handlers already surface. Adds regression tests for the new error replies, the upload resume fallback, and DecodeNewsPath's malformed-input handling.
-rw-r--r--hotline/field.go14
-rw-r--r--hotline/field_test.go16
-rw-r--r--internal/mobius/transaction_handlers.go224
-rw-r--r--internal/mobius/transaction_handlers_test.go144
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