diff options
| author | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-11-28 00:39:17 +0100 |
|---|---|---|
| committer | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-11-28 00:39:17 +0100 |
| commit | 9f67542d8469db45c823e347b1868b3582d9e5a7 (patch) | |
| tree | 88741b3d8633758e4f6f5cbc292f338bc99602a0 /hotline | |
| parent | 8f9edf2f3bb18f7ab1a04ead182a1daf2cfd41d9 (diff) | |
| parent | 8ddb9bb228389b198a76d6df21de005da4fad66b (diff) | |
Diffstat (limited to 'hotline')
| -rw-r--r-- | hotline/access.go | 164 | ||||
| -rw-r--r-- | hotline/access_test.go | 407 | ||||
| -rw-r--r-- | hotline/account_manager.go | 2 | ||||
| -rw-r--r-- | hotline/config.go | 2 | ||||
| -rw-r--r-- | hotline/field.go | 125 | ||||
| -rw-r--r-- | hotline/field_test.go | 77 | ||||
| -rw-r--r-- | hotline/file_path.go | 8 | ||||
| -rw-r--r-- | hotline/file_resume_data.go | 62 | ||||
| -rw-r--r-- | hotline/file_transfer.go | 121 | ||||
| -rw-r--r-- | hotline/file_transfer_test.go | 91 | ||||
| -rw-r--r-- | hotline/file_wrapper.go | 54 | ||||
| -rw-r--r-- | hotline/files.go | 12 | ||||
| -rw-r--r-- | hotline/files_test.go | 2 | ||||
| -rw-r--r-- | hotline/flattened_file_object.go | 4 | ||||
| -rw-r--r-- | hotline/news.go | 25 | ||||
| -rw-r--r-- | hotline/news_test.go | 271 | ||||
| -rw-r--r-- | hotline/server.go | 277 | ||||
| -rw-r--r-- | hotline/server_test.go | 583 | ||||
| -rw-r--r-- | hotline/stats.go | 4 | ||||
| -rw-r--r-- | hotline/stats_test.go | 293 | ||||
| -rw-r--r-- | hotline/tracker.go | 121 | ||||
| -rw-r--r-- | hotline/tracker_test.go | 383 | ||||
| -rw-r--r-- | hotline/transaction.go | 2 | ||||
| -rw-r--r-- | hotline/transfer_test.go | 6 |
24 files changed, 2512 insertions, 584 deletions
diff --git a/hotline/access.go b/hotline/access.go index 42e96c4..370f8c2 100644 --- a/hotline/access.go +++ b/hotline/access.go @@ -73,125 +73,53 @@ func (bits *AccessBitmap) UnmarshalYAML(unmarshal func(interface{}) error) error case map[string]interface{}: // Mobius versions >= v0.17.0 store the user access bitmap as map[string]bool to provide a human-readable view of // the account permissions. - if f, ok := v["DeleteFile"].(bool); ok && f { - bits.Set(AccessDeleteFile) + accessMap := map[string]int{ + "DeleteFile": AccessDeleteFile, + "UploadFile": AccessUploadFile, + "DownloadFile": AccessDownloadFile, + "RenameFile": AccessRenameFile, + "MoveFile": AccessMoveFile, + "CreateFolder": AccessCreateFolder, + "DeleteFolder": AccessDeleteFolder, + "RenameFolder": AccessRenameFolder, + "MoveFolder": AccessMoveFolder, + "ReadChat": AccessReadChat, + "SendChat": AccessSendChat, + "OpenChat": AccessOpenChat, + "CloseChat": AccessCloseChat, + "ShowInList": AccessShowInList, + "CreateUser": AccessCreateUser, + "DeleteUser": AccessDeleteUser, + "OpenUser": AccessOpenUser, + "ModifyUser": AccessModifyUser, + "ChangeOwnPass": AccessChangeOwnPass, + "NewsReadArt": AccessNewsReadArt, + "NewsPostArt": AccessNewsPostArt, + "DisconnectUser": AccessDisconUser, + "CannotBeDisconnected": AccessCannotBeDiscon, + "GetClientInfo": AccessGetClientInfo, + "UploadAnywhere": AccessUploadAnywhere, + "AnyName": AccessAnyName, + "NoAgreement": AccessNoAgreement, + "SetFileComment": AccessSetFileComment, + "SetFolderComment": AccessSetFolderComment, + "ViewDropBoxes": AccessViewDropBoxes, + "MakeAlias": AccessMakeAlias, + "Broadcast": AccessBroadcast, + "NewsDeleteArt": AccessNewsDeleteArt, + "NewsCreateCat": AccessNewsCreateCat, + "NewsDeleteCat": AccessNewsDeleteCat, + "NewsCreateFldr": AccessNewsCreateFldr, + "NewsDeleteFldr": AccessNewsDeleteFldr, + "SendPrivMsg": AccessSendPrivMsg, + "UploadFolder": AccessUploadFolder, + "DownloadFolder": AccessDownloadFolder, } - if f, ok := v["UploadFile"].(bool); ok && f { - bits.Set(AccessUploadFile) - } - if f, ok := v["DownloadFile"].(bool); ok && f { - bits.Set(AccessDownloadFile) - } - if f, ok := v["UploadFolder"].(bool); ok && f { - bits.Set(AccessUploadFolder) - } - if f, ok := v["DownloadFolder"].(bool); ok && f { - bits.Set(AccessDownloadFolder) - } - if f, ok := v["RenameFile"].(bool); ok && f { - bits.Set(AccessRenameFile) - } - if f, ok := v["MoveFile"].(bool); ok && f { - bits.Set(AccessMoveFile) - } - if f, ok := v["CreateFolder"].(bool); ok && f { - bits.Set(AccessCreateFolder) - } - if f, ok := v["DeleteFolder"].(bool); ok && f { - bits.Set(AccessDeleteFolder) - } - if f, ok := v["RenameFolder"].(bool); ok && f { - bits.Set(AccessRenameFolder) - } - if f, ok := v["MoveFolder"].(bool); ok && f { - bits.Set(AccessMoveFolder) - } - if f, ok := v["ReadChat"].(bool); ok && f { - bits.Set(AccessReadChat) - } - if f, ok := v["SendChat"].(bool); ok && f { - bits.Set(AccessSendChat) - } - if f, ok := v["OpenChat"].(bool); ok && f { - bits.Set(AccessOpenChat) - } - if f, ok := v["CloseChat"].(bool); ok && f { - bits.Set(AccessCloseChat) - } - if f, ok := v["ShowInList"].(bool); ok && f { - bits.Set(AccessShowInList) - } - if f, ok := v["CreateUser"].(bool); ok && f { - bits.Set(AccessCreateUser) - } - if f, ok := v["DeleteUser"].(bool); ok && f { - bits.Set(AccessDeleteUser) - } - if f, ok := v["OpenUser"].(bool); ok && f { - bits.Set(AccessOpenUser) - } - if f, ok := v["ModifyUser"].(bool); ok && f { - bits.Set(AccessModifyUser) - } - if f, ok := v["ChangeOwnPass"].(bool); ok && f { - bits.Set(AccessChangeOwnPass) - } - if f, ok := v["NewsReadArt"].(bool); ok && f { - bits.Set(AccessNewsReadArt) - } - if f, ok := v["NewsPostArt"].(bool); ok && f { - bits.Set(AccessNewsPostArt) - } - if f, ok := v["DisconnectUser"].(bool); ok && f { - bits.Set(AccessDisconUser) - } - if f, ok := v["CannotBeDisconnected"].(bool); ok && f { - bits.Set(AccessCannotBeDiscon) - } - if f, ok := v["GetClientInfo"].(bool); ok && f { - bits.Set(AccessGetClientInfo) - } - if f, ok := v["UploadAnywhere"].(bool); ok && f { - bits.Set(AccessUploadAnywhere) - } - if f, ok := v["AnyName"].(bool); ok && f { - bits.Set(AccessAnyName) - } - if f, ok := v["NoAgreement"].(bool); ok && f { - bits.Set(AccessNoAgreement) - } - if f, ok := v["SetFileComment"].(bool); ok && f { - bits.Set(AccessSetFileComment) - } - if f, ok := v["SetFolderComment"].(bool); ok && f { - bits.Set(AccessSetFolderComment) - } - if f, ok := v["ViewDropBoxes"].(bool); ok && f { - bits.Set(AccessViewDropBoxes) - } - if f, ok := v["MakeAlias"].(bool); ok && f { - bits.Set(AccessMakeAlias) - } - if f, ok := v["Broadcast"].(bool); ok && f { - bits.Set(AccessBroadcast) - } - if f, ok := v["NewsDeleteArt"].(bool); ok && f { - bits.Set(AccessNewsDeleteArt) - } - if f, ok := v["NewsCreateCat"].(bool); ok && f { - bits.Set(AccessNewsCreateCat) - } - if f, ok := v["NewsDeleteCat"].(bool); ok && f { - bits.Set(AccessNewsDeleteCat) - } - if f, ok := v["NewsCreateFldr"].(bool); ok && f { - bits.Set(AccessNewsCreateFldr) - } - if f, ok := v["NewsDeleteFldr"].(bool); ok && f { - bits.Set(AccessNewsDeleteFldr) - } - if f, ok := v["SendPrivMsg"].(bool); ok && f { - bits.Set(AccessSendPrivMsg) + + for key, accessBit := range accessMap { + if flag, ok := v[key].(bool); ok && flag { + bits.Set(accessBit) + } } } diff --git a/hotline/access_test.go b/hotline/access_test.go index f2755f3..fa2f6f6 100644 --- a/hotline/access_test.go +++ b/hotline/access_test.go @@ -2,6 +2,9 @@ package hotline import ( "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + "strings" "testing" ) @@ -37,3 +40,407 @@ func Test_accessBitmap_IsSet(t *testing.T) { }) } } + +func Test_accessBitmap_UnmarshalYAML(t *testing.T) { + // Test direct method call for explicit coverage + t.Run("direct method call", func(t *testing.T) { + var bits AccessBitmap + err := bits.UnmarshalYAML(func(v interface{}) error { + switch ptr := v.(type) { + case *interface{}: + *ptr = map[string]interface{}{ + "DownloadFile": true, + "UploadFile": true, + } + } + return nil + }) + require.NoError(t, err) + assert.True(t, bits.IsSet(AccessDownloadFile)) + assert.True(t, bits.IsSet(AccessUploadFile)) + }) + + // Original YAML unmarshaling tests + tests := []struct { + name string + yamlData string + expected AccessBitmap + wantErr bool + }{ + { + name: "unmarshal array format (legacy)", + yamlData: "access: [96, 112, 12, 32, 3, 128, 0, 0]", + expected: AccessBitmap{96, 112, 12, 32, 3, 128, 0, 0}, + wantErr: false, + }, + { + name: "unmarshal map format with true values", + yamlData: `access: + DownloadFile: true + UploadFile: true + DeleteFile: true`, + expected: func() AccessBitmap { + var bits AccessBitmap + bits.Set(AccessDownloadFile) + bits.Set(AccessUploadFile) + bits.Set(AccessDeleteFile) + return bits + }(), + wantErr: false, + }, + { + name: "unmarshal map format with false values", + yamlData: `access: + DownloadFile: false + UploadFile: false + DeleteFile: false`, + expected: AccessBitmap{}, + wantErr: false, + }, + { + name: "unmarshal map format with mixed values", + yamlData: `access: + DownloadFile: true + UploadFile: false + DeleteFile: true + CreateFolder: true`, + expected: func() AccessBitmap { + var bits AccessBitmap + bits.Set(AccessDownloadFile) + bits.Set(AccessDeleteFile) + bits.Set(AccessCreateFolder) + return bits + }(), + wantErr: false, + }, + { + name: "unmarshal map format with all permissions", + yamlData: `access: + DeleteFile: true + UploadFile: true + DownloadFile: true + RenameFile: true + MoveFile: true + CreateFolder: true + DeleteFolder: true + RenameFolder: true + MoveFolder: true + ReadChat: true + SendChat: true + OpenChat: true + CloseChat: true + ShowInList: true + CreateUser: true + DeleteUser: true + OpenUser: true + ModifyUser: true + ChangeOwnPass: true + NewsReadArt: true + NewsPostArt: true + DisconnectUser: true + CannotBeDisconnected: true + GetClientInfo: true + UploadAnywhere: true + AnyName: true + NoAgreement: true + SetFileComment: true + SetFolderComment: true + ViewDropBoxes: true + MakeAlias: true + Broadcast: true + NewsDeleteArt: true + NewsCreateCat: true + NewsDeleteCat: true + NewsCreateFldr: true + NewsDeleteFldr: true + SendPrivMsg: true + UploadFolder: true + DownloadFolder: true`, + expected: func() AccessBitmap { + var bits AccessBitmap + bits.Set(AccessDeleteFile) + bits.Set(AccessUploadFile) + bits.Set(AccessDownloadFile) + bits.Set(AccessRenameFile) + bits.Set(AccessMoveFile) + bits.Set(AccessCreateFolder) + bits.Set(AccessDeleteFolder) + bits.Set(AccessRenameFolder) + bits.Set(AccessMoveFolder) + bits.Set(AccessReadChat) + bits.Set(AccessSendChat) + bits.Set(AccessOpenChat) + bits.Set(AccessCloseChat) + bits.Set(AccessShowInList) + bits.Set(AccessCreateUser) + bits.Set(AccessDeleteUser) + bits.Set(AccessOpenUser) + bits.Set(AccessModifyUser) + bits.Set(AccessChangeOwnPass) + bits.Set(AccessNewsReadArt) + bits.Set(AccessNewsPostArt) + bits.Set(AccessDisconUser) + bits.Set(AccessCannotBeDiscon) + bits.Set(AccessGetClientInfo) + bits.Set(AccessUploadAnywhere) + bits.Set(AccessAnyName) + bits.Set(AccessNoAgreement) + bits.Set(AccessSetFileComment) + bits.Set(AccessSetFolderComment) + bits.Set(AccessViewDropBoxes) + bits.Set(AccessMakeAlias) + bits.Set(AccessBroadcast) + bits.Set(AccessNewsDeleteArt) + bits.Set(AccessNewsCreateCat) + bits.Set(AccessNewsDeleteCat) + bits.Set(AccessNewsCreateFldr) + bits.Set(AccessNewsDeleteFldr) + bits.Set(AccessSendPrivMsg) + bits.Set(AccessUploadFolder) + bits.Set(AccessDownloadFolder) + return bits + }(), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var data struct { + Access AccessBitmap `yaml:"access"` + } + + err := yaml.Unmarshal([]byte(tt.yamlData), &data) + + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expected, data.Access) + }) + } +} + +func Test_accessBitmap_MarshalYAML(t *testing.T) { + tests := []struct { + name string + bits AccessBitmap + expected string + }{ + { + name: "empty access bitmap", + bits: AccessBitmap{}, + expected: ` DownloadFile: false + DownloadFolder: false + UploadFile: false + UploadFolder: false + DeleteFile: false + RenameFile: false + MoveFile: false + CreateFolder: false + DeleteFolder: false + RenameFolder: false + MoveFolder: false + ReadChat: false + SendChat: false + OpenChat: false + CloseChat: false + ShowInList: false + CreateUser: false + DeleteUser: false + OpenUser: false + ModifyUser: false + ChangeOwnPass: false + NewsReadArt: false + NewsPostArt: false + DisconnectUser: false + CannotBeDisconnected: false + GetClientInfo: false + UploadAnywhere: false + AnyName: false + NoAgreement: false + SetFileComment: false + SetFolderComment: false + ViewDropBoxes: false + MakeAlias: false + Broadcast: false + NewsDeleteArt: false + NewsCreateCat: false + NewsDeleteCat: false + NewsCreateFldr: false + NewsDeleteFldr: false + SendPrivMsg: false +`, + }, + { + name: "access bitmap with some permissions", + bits: func() AccessBitmap { + var bits AccessBitmap + bits.Set(AccessDownloadFile) + bits.Set(AccessUploadFile) + bits.Set(AccessCreateFolder) + bits.Set(AccessReadChat) + return bits + }(), + expected: ` DownloadFile: true + DownloadFolder: false + UploadFile: true + UploadFolder: false + DeleteFile: false + RenameFile: false + MoveFile: false + CreateFolder: true + DeleteFolder: false + RenameFolder: false + MoveFolder: false + ReadChat: true + SendChat: false + OpenChat: false + CloseChat: false + ShowInList: false + CreateUser: false + DeleteUser: false + OpenUser: false + ModifyUser: false + ChangeOwnPass: false + NewsReadArt: false + NewsPostArt: false + DisconnectUser: false + CannotBeDisconnected: false + GetClientInfo: false + UploadAnywhere: false + AnyName: false + NoAgreement: false + SetFileComment: false + SetFolderComment: false + ViewDropBoxes: false + MakeAlias: false + Broadcast: false + NewsDeleteArt: false + NewsCreateCat: false + NewsDeleteCat: false + NewsCreateFldr: false + NewsDeleteFldr: false + SendPrivMsg: false +`, + }, + { + name: "access bitmap with all permissions", + bits: func() AccessBitmap { + var bits AccessBitmap + bits.Set(AccessDeleteFile) + bits.Set(AccessUploadFile) + bits.Set(AccessDownloadFile) + bits.Set(AccessRenameFile) + bits.Set(AccessMoveFile) + bits.Set(AccessCreateFolder) + bits.Set(AccessDeleteFolder) + bits.Set(AccessRenameFolder) + bits.Set(AccessMoveFolder) + bits.Set(AccessReadChat) + bits.Set(AccessSendChat) + bits.Set(AccessOpenChat) + bits.Set(AccessCloseChat) + bits.Set(AccessShowInList) + bits.Set(AccessCreateUser) + bits.Set(AccessDeleteUser) + bits.Set(AccessOpenUser) + bits.Set(AccessModifyUser) + bits.Set(AccessChangeOwnPass) + bits.Set(AccessNewsReadArt) + bits.Set(AccessNewsPostArt) + bits.Set(AccessDisconUser) + bits.Set(AccessCannotBeDiscon) + bits.Set(AccessGetClientInfo) + bits.Set(AccessUploadAnywhere) + bits.Set(AccessAnyName) + bits.Set(AccessNoAgreement) + bits.Set(AccessSetFileComment) + bits.Set(AccessSetFolderComment) + bits.Set(AccessViewDropBoxes) + bits.Set(AccessMakeAlias) + bits.Set(AccessBroadcast) + bits.Set(AccessNewsDeleteArt) + bits.Set(AccessNewsCreateCat) + bits.Set(AccessNewsDeleteCat) + bits.Set(AccessNewsCreateFldr) + bits.Set(AccessNewsDeleteFldr) + bits.Set(AccessSendPrivMsg) + bits.Set(AccessUploadFolder) + bits.Set(AccessDownloadFolder) + return bits + }(), + expected: ` DownloadFile: true + DownloadFolder: true + UploadFile: true + UploadFolder: true + DeleteFile: true + RenameFile: true + MoveFile: true + CreateFolder: true + DeleteFolder: true + RenameFolder: true + MoveFolder: true + ReadChat: true + SendChat: true + OpenChat: true + CloseChat: true + ShowInList: true + CreateUser: true + DeleteUser: true + OpenUser: true + ModifyUser: true + ChangeOwnPass: true + NewsReadArt: true + NewsPostArt: true + DisconnectUser: true + CannotBeDisconnected: true + GetClientInfo: true + UploadAnywhere: true + AnyName: true + NoAgreement: true + SetFileComment: true + SetFolderComment: true + ViewDropBoxes: true + MakeAlias: true + Broadcast: true + NewsDeleteArt: true + NewsCreateCat: true + NewsDeleteCat: true + NewsCreateFldr: true + NewsDeleteFldr: true + SendPrivMsg: true +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test via YAML marshaling + yamlData, err := yaml.Marshal(struct { + Access AccessBitmap `yaml:"access"` + }{Access: tt.bits}) + + require.NoError(t, err) + + // Extract just the access portion + lines := strings.Split(string(yamlData), "\n") + var accessLines []string + inAccess := false + for _, line := range lines { + if strings.HasPrefix(line, "access:") { + inAccess = true + continue + } + if inAccess && strings.HasPrefix(line, " ") { + accessLines = append(accessLines, line) + } + } + + actualAccess := strings.Join(accessLines, "\n") + "\n" + assert.Equal(t, tt.expected, actualAccess) + }) + } +} diff --git a/hotline/account_manager.go b/hotline/account_manager.go index 03621df..52c24f8 100644 --- a/hotline/account_manager.go +++ b/hotline/account_manager.go @@ -1,5 +1,7 @@ package hotline +// AccountManager provides an interface for managing user accounts, +// including creation, retrieval, updates, and deletion operations. type AccountManager interface { Create(account Account) error Update(account Account, newLogin string) error diff --git a/hotline/config.go b/hotline/config.go index a7bd81e..75d5c4d 100644 --- a/hotline/config.go +++ b/hotline/config.go @@ -3,7 +3,7 @@ package hotline type Config struct { Name string `yaml:"Name" validate:"required,max=50"` // Name used for Tracker registration Description string `yaml:"Description" validate:"required,max=200"` // Description used for Tracker registration - BannerFile string `yaml:"BannerFile"` // Path to Banner jpg + BannerFile string `yaml:"BannerFile" validate:"omitempty,bannerext"` // Path to Banner jpg or gif FileRoot string `yaml:"FileRoot" validate:"required"` // Path to Files EnableTrackerRegistration bool `yaml:"EnableTrackerRegistration"` // Toggle Tracker Registration Trackers []string `yaml:"Trackers" validate:"dive,hostname_port"` // List of trackers that the server should register with diff --git a/hotline/field.go b/hotline/field.go index 65ff394..1e6fc16 100644 --- a/hotline/field.go +++ b/hotline/field.go @@ -9,77 +9,80 @@ import ( "slices" ) +// FieldType represents a Hotline protocol field type identifier +type FieldType [2]byte + // List of Hotline protocol field types taken from the official 1.9 protocol document var ( - FieldError = [2]byte{0x00, 0x64} // 100 - FieldData = [2]byte{0x00, 0x65} // 101 - FieldUserName = [2]byte{0x00, 0x66} // 102 - FieldUserID = [2]byte{0x00, 0x67} // 103 - FieldUserIconID = [2]byte{0x00, 0x68} // 104 - FieldUserLogin = [2]byte{0x00, 0x69} // 105 - FieldUserPassword = [2]byte{0x00, 0x6A} // 106 - FieldRefNum = [2]byte{0x00, 0x6B} // 107 - FieldTransferSize = [2]byte{0x00, 0x6C} // 108 - FieldChatOptions = [2]byte{0x00, 0x6D} // 109 - FieldUserAccess = [2]byte{0x00, 0x6E} // 110 - FieldUserFlags = [2]byte{0x00, 0x70} // 112 - FieldOptions = [2]byte{0x00, 0x71} // 113 - FieldChatID = [2]byte{0x00, 0x72} // 114 - FieldChatSubject = [2]byte{0x00, 0x73} // 115 - FieldWaitingCount = [2]byte{0x00, 0x74} // 116 - FieldBannerType = [2]byte{0x00, 0x98} // 152 - FieldNoServerAgreement = [2]byte{0x00, 0x98} // 152 - FieldVersion = [2]byte{0x00, 0xA0} // 160 - FieldCommunityBannerID = [2]byte{0x00, 0xA1} // 161 - FieldServerName = [2]byte{0x00, 0xA2} // 162 - FieldFileNameWithInfo = [2]byte{0x00, 0xC8} // 200 - FieldFileName = [2]byte{0x00, 0xC9} // 201 - FieldFilePath = [2]byte{0x00, 0xCA} // 202 - FieldFileResumeData = [2]byte{0x00, 0xCB} // 203 - FieldFileTransferOptions = [2]byte{0x00, 0xCC} // 204 - FieldFileTypeString = [2]byte{0x00, 0xCD} // 205 - FieldFileCreatorString = [2]byte{0x00, 0xCE} // 206 - FieldFileSize = [2]byte{0x00, 0xCF} // 207 - FieldFileCreateDate = [2]byte{0x00, 0xD0} // 208 - FieldFileModifyDate = [2]byte{0x00, 0xD1} // 209 - FieldFileComment = [2]byte{0x00, 0xD2} // 210 - FieldFileNewName = [2]byte{0x00, 0xD3} // 211 - FieldFileNewPath = [2]byte{0x00, 0xD4} // 212 - FieldFileType = [2]byte{0x00, 0xD5} // 213 - FieldQuotingMsg = [2]byte{0x00, 0xD6} // 214 - FieldAutomaticResponse = [2]byte{0x00, 0xD7} // 215 - FieldFolderItemCount = [2]byte{0x00, 0xDC} // 220 - FieldUsernameWithInfo = [2]byte{0x01, 0x2C} // 300 - FieldNewsArtListData = [2]byte{0x01, 0x41} // 321 - FieldNewsCatName = [2]byte{0x01, 0x42} // 322 - FieldNewsCatListData15 = [2]byte{0x01, 0x43} // 323 - FieldNewsPath = [2]byte{0x01, 0x45} // 325 - FieldNewsArtID = [2]byte{0x01, 0x46} // 326 - FieldNewsArtDataFlav = [2]byte{0x01, 0x47} // 327 - FieldNewsArtTitle = [2]byte{0x01, 0x48} // 328 - FieldNewsArtPoster = [2]byte{0x01, 0x49} // 329 - FieldNewsArtDate = [2]byte{0x01, 0x4A} // 330 - FieldNewsArtPrevArt = [2]byte{0x01, 0x4B} // 331 - FieldNewsArtNextArt = [2]byte{0x01, 0x4C} // 332 - FieldNewsArtData = [2]byte{0x01, 0x4D} // 333 - FieldNewsArtParentArt = [2]byte{0x01, 0x4F} // 335 - FieldNewsArt1stChildArt = [2]byte{0x01, 0x50} // 336 - FieldNewsArtRecurseDel = [2]byte{0x01, 0x51} // 337 + FieldError = FieldType{0x00, 0x64} // 100 + FieldData = FieldType{0x00, 0x65} // 101 + FieldUserName = FieldType{0x00, 0x66} // 102 + FieldUserID = FieldType{0x00, 0x67} // 103 + FieldUserIconID = FieldType{0x00, 0x68} // 104 + FieldUserLogin = FieldType{0x00, 0x69} // 105 + FieldUserPassword = FieldType{0x00, 0x6A} // 106 + FieldRefNum = FieldType{0x00, 0x6B} // 107 + FieldTransferSize = FieldType{0x00, 0x6C} // 108 + FieldChatOptions = FieldType{0x00, 0x6D} // 109 + FieldUserAccess = FieldType{0x00, 0x6E} // 110 + FieldUserFlags = FieldType{0x00, 0x70} // 112 + FieldOptions = FieldType{0x00, 0x71} // 113 + FieldChatID = FieldType{0x00, 0x72} // 114 + FieldChatSubject = FieldType{0x00, 0x73} // 115 + FieldWaitingCount = FieldType{0x00, 0x74} // 116 + FieldBannerType = FieldType{0x00, 0x98} // 152 + FieldNoServerAgreement = FieldType{0x00, 0x98} // 152 + FieldVersion = FieldType{0x00, 0xA0} // 160 + FieldCommunityBannerID = FieldType{0x00, 0xA1} // 161 + FieldServerName = FieldType{0x00, 0xA2} // 162 + FieldFileNameWithInfo = FieldType{0x00, 0xC8} // 200 + FieldFileName = FieldType{0x00, 0xC9} // 201 + FieldFilePath = FieldType{0x00, 0xCA} // 202 + FieldFileResumeData = FieldType{0x00, 0xCB} // 203 + FieldFileTransferOptions = FieldType{0x00, 0xCC} // 204 + FieldFileTypeString = FieldType{0x00, 0xCD} // 205 + FieldFileCreatorString = FieldType{0x00, 0xCE} // 206 + FieldFileSize = FieldType{0x00, 0xCF} // 207 + FieldFileCreateDate = FieldType{0x00, 0xD0} // 208 + FieldFileModifyDate = FieldType{0x00, 0xD1} // 209 + FieldFileComment = FieldType{0x00, 0xD2} // 210 + FieldFileNewName = FieldType{0x00, 0xD3} // 211 + FieldFileNewPath = FieldType{0x00, 0xD4} // 212 + FieldFileType = FieldType{0x00, 0xD5} // 213 + FieldQuotingMsg = FieldType{0x00, 0xD6} // 214 + FieldAutomaticResponse = FieldType{0x00, 0xD7} // 215 + FieldFolderItemCount = FieldType{0x00, 0xDC} // 220 + FieldUsernameWithInfo = FieldType{0x01, 0x2C} // 300 + FieldNewsArtListData = FieldType{0x01, 0x41} // 321 + FieldNewsCatName = FieldType{0x01, 0x42} // 322 + FieldNewsCatListData15 = FieldType{0x01, 0x43} // 323 + FieldNewsPath = FieldType{0x01, 0x45} // 325 + FieldNewsArtID = FieldType{0x01, 0x46} // 326 + FieldNewsArtDataFlav = FieldType{0x01, 0x47} // 327 + FieldNewsArtTitle = FieldType{0x01, 0x48} // 328 + FieldNewsArtPoster = FieldType{0x01, 0x49} // 329 + FieldNewsArtDate = FieldType{0x01, 0x4A} // 330 + FieldNewsArtPrevArt = FieldType{0x01, 0x4B} // 331 + FieldNewsArtNextArt = FieldType{0x01, 0x4C} // 332 + FieldNewsArtData = FieldType{0x01, 0x4D} // 333 + FieldNewsArtParentArt = FieldType{0x01, 0x4F} // 335 + FieldNewsArt1stChildArt = FieldType{0x01, 0x50} // 336 + FieldNewsArtRecurseDel = FieldType{0x01, 0x51} // 337 // These fields are documented, but seemingly unused. - // FieldUserAlias = [2]byte{0x00, 0x6F} // 111 - // FieldNewsArtFlags = [2]byte{0x01, 0x4E} // 334 + // FieldUserAlias = FieldType{0x00, 0x6F} // 111 + // FieldNewsArtFlags = FieldType{0x01, 0x4E} // 334 ) type Field struct { - Type [2]byte // Type of field - FieldSize [2]byte // Size of the data field - Data []byte // Field data + Type FieldType // Type of field + FieldSize [2]byte // Size of the data field + Data []byte // Field data readOffset int // Internal offset to track read progress } -func NewField(fieldType [2]byte, data []byte) Field { +func NewField(fieldType FieldType, data []byte) Field { f := Field{ Type: fieldType, Data: make([]byte, len(data)), @@ -185,7 +188,7 @@ func (f *Field) Write(p []byte) (int, error) { return minFieldLen + dataSize, nil } -func GetField(id [2]byte, fields *[]Field) *Field { +func GetField(id FieldType, fields *[]Field) *Field { for _, field := range *fields { if id == field.Type { return &field diff --git a/hotline/field_test.go b/hotline/field_test.go index 213d9c3..3440b0d 100644 --- a/hotline/field_test.go +++ b/hotline/field_test.go @@ -102,7 +102,7 @@ func Test_fieldScanner(t *testing.T) { func TestField_Read(t *testing.T) { type fields struct { - ID [2]byte + Type FieldType FieldSize [2]byte Data []byte readOffset int @@ -121,7 +121,7 @@ func TestField_Read(t *testing.T) { { name: "returns field bytes", fields: fields{ - ID: [2]byte{0x00, 0x62}, + Type: FieldType{0x00, 0x62}, FieldSize: [2]byte{0x00, 0x03}, Data: []byte("hai!"), }, @@ -139,7 +139,7 @@ func TestField_Read(t *testing.T) { { name: "returns field bytes from readOffset", fields: fields{ - ID: [2]byte{0x00, 0x62}, + Type: FieldType{0x00, 0x62}, FieldSize: [2]byte{0x00, 0x03}, Data: []byte("hai!"), readOffset: 4, @@ -156,7 +156,7 @@ func TestField_Read(t *testing.T) { { name: "returns io.EOF when all bytes read", fields: fields{ - ID: [2]byte{0x00, 0x62}, + Type: FieldType{0x00, 0x62}, FieldSize: [2]byte{0x00, 0x03}, Data: []byte("hai!"), readOffset: 8, @@ -172,7 +172,7 @@ func TestField_Read(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f := &Field{ - Type: tt.fields.ID, + Type: tt.fields.Type, FieldSize: tt.fields.FieldSize, Data: tt.fields.Data, readOffset: tt.fields.readOffset, @@ -229,3 +229,70 @@ func TestField_DecodeInt(t *testing.T) { }) } } + +func TestField_DecodeNewsPath(t *testing.T) { + type fields struct { + Data []byte + } + tests := []struct { + name string + fields fields + want []string + wantErr assert.ErrorAssertionFunc + }{ + { + name: "empty field data", + fields: fields{Data: []byte{}}, + want: []string{}, + wantErr: assert.NoError, + }, + { + name: "single path", + fields: fields{Data: []byte{ + 0x00, 0x01, // path count = 1 + 0x00, 0x00, 0x05, // 2 bytes unused + 1 byte length (5) + 0x48, 0x65, 0x6c, 0x6c, 0x6f, // "Hello" + }}, + want: []string{"Hello"}, + wantErr: assert.NoError, + }, + { + name: "multiple paths", + 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" + 0x00, 0x00, 0x05, // 2 bytes unused + 1 byte length (5) + 0x57, 0x6f, 0x72, 0x6c, 0x64, // "World" + }}, + want: []string{"Hello", "World"}, + wantErr: assert.NoError, + }, + { + name: "example from comments - nested categories", + fields: fields{Data: []byte{ + 0x00, 0x03, // path count = 3 + 0x00, 0x00, 0x10, // 2 bytes unused + 1 byte length (16) + 0x54, 0x6f, 0x70, 0x20, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x20, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, // "Top Level Bundle" + 0x00, 0x00, 0x13, // 2 bytes unused + 1 byte length (19) + 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x20, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x20, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, // "Second Level Bundle" + 0x00, 0x00, 0x0f, // 2 bytes unused + 1 byte length (15) + 0x4e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, // "Nested Category" + }}, + want: []string{"Top Level Bundle", "Second Level Bundle", "Nested Category"}, + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &Field{ + Data: tt.fields.Data, + } + got, err := f.DecodeNewsPath() + if !tt.wantErr(t, err, "DecodeNewsPath()") { + return + } + assert.Equalf(t, tt.want, got, "DecodeNewsPath()") + }) + } +} diff --git a/hotline/file_path.go b/hotline/file_path.go index bb6bbd3..aba3195 100644 --- a/hotline/file_path.go +++ b/hotline/file_path.go @@ -7,7 +7,7 @@ import ( "errors" "fmt" "io" - "path/filepath" + "path" "strings" ) @@ -121,13 +121,13 @@ func ReadPath(fileRoot string, filePath, fileName []byte) (fullPath string, err var subPath string for _, pathItem := range fp.Items { - subPath = filepath.Join("/", subPath, string(pathItem.Name)) + subPath = path.Join("/", subPath, string(pathItem.Name)) } - fullPath = filepath.Join( + fullPath = path.Join( fileRoot, subPath, - filepath.Join("/", string(fileName)), + path.Join("/", string(fileName)), ) fullPath, err = txtDecoder.String(fullPath) if err != nil { diff --git a/hotline/file_resume_data.go b/hotline/file_resume_data.go index 5dfd2d8..1926bc6 100644 --- a/hotline/file_resume_data.go +++ b/hotline/file_resume_data.go @@ -3,38 +3,59 @@ package hotline import ( "bytes" "encoding/binary" + "fmt" ) // FileResumeData is sent when a client or server would like to resume a transfer from an offset type FileResumeData struct { Format [4]byte // "RFLT" Version [2]byte // Always 1 - RSVD [34]byte // Unused + RSVD [34]byte // Present in the Hotline protocol docs, but unused. Left here for documentation purposes. ForkCount [2]byte // Length of ForkInfoList. Either 2 or 3 depending on whether file has a resource fork ForkInfoList []ForkInfoList +} + +// ForkType represents a 4-byte fork type identifier +type ForkType [4]byte - //readOffset int // TODO +// String returns a string representation of the fork type +func (ft ForkType) String() string { + return fmt.Sprintf("%c%c%c%c", ft[0], ft[1], ft[2], ft[3]) } type ForkInfoList struct { - Fork [4]byte // "DATA" or "MACR" - DataSize [4]byte // offset from which to resume the transfer of data - RSVDA [4]byte // Unused - RSVDB [4]byte // Unused + Fork ForkType // "DATA", "INFO", or "MACR" + DataSize [4]byte // offset from which to resume the transfer of data + RSVDA [4]byte // Present in the Hotline protocol docs, but unused. Left here for documentation purposes. + RSVDB [4]byte // Present in the Hotline protocol docs, but unused. Left here for documentation purposes. } +var ( + ForkTypeDATA = ForkType{0x44, 0x41, 0x54, 0x41} // DATA: Data fork + ForkTypeINFO = ForkType{0x49, 0x4E, 0x46, 0x4F} // INFO: Information fork + ForkTypeMACR = ForkType{0x4d, 0x41, 0x43, 0x52} // MACR: Mac resource fork +) + func NewForkInfoList(b []byte) *ForkInfoList { return &ForkInfoList{ - Fork: [4]byte{0x44, 0x41, 0x54, 0x41}, + Fork: ForkTypeDATA, DataSize: [4]byte{b[0], b[1], b[2], b[3]}, RSVDA: [4]byte{}, RSVDB: [4]byte{}, } } +var ( + FormatFILP = [4]byte{0x46, 0x49, 0x4c, 0x50} // Flattened file format: "FILP" + FormatRFLT = [4]byte{0x52, 0x46, 0x4C, 0x54} // File resume format: "RFLT" (?) + + PlatformAMAC = [4]byte{0x41, 0x4D, 0x41, 0x43} // Mac platform: "AMAC" + PlatformMWIN = [4]byte{0x4D, 0x57, 0x49, 0x4E} // Windows platform: "MWIN" +) + func NewFileResumeData(list []ForkInfoList) *FileResumeData { return &FileResumeData{ - Format: [4]byte{0x52, 0x46, 0x4C, 0x54}, // RFLT + Format: FormatRFLT, Version: [2]byte{0, 1}, RSVD: [34]byte{}, ForkCount: [2]byte{0, uint8(len(list))}, @@ -42,31 +63,6 @@ func NewFileResumeData(list []ForkInfoList) *FileResumeData { } } -// -//func (frd *FileResumeData) Read(p []byte) (int, error) { -// buf := slices.Concat( -// frd.Format[:], -// frd.Version[:], -// frd.RSVD[:], -// frd.ForkCount[:], -// ) -// for _, fil := range frd.ForkInfoList { -// buf = append(buf, fil...) -// _ = binary.Write(&buf, binary.LittleEndian, fil) -// } -// -// var buf bytes.Buffer -// _ = binary.Write(&buf, binary.LittleEndian, frd.Format) -// _ = binary.Write(&buf, binary.LittleEndian, frd.Version) -// _ = binary.Write(&buf, binary.LittleEndian, frd.RSVD) -// _ = binary.Write(&buf, binary.LittleEndian, frd.ForkCount) -// for _, fil := range frd.ForkInfoList { -// _ = binary.Write(&buf, binary.LittleEndian, fil) -// } -// -// return buf.Bytes(), nil -//} - func (frd *FileResumeData) BinaryMarshal() ([]byte, error) { var buf bytes.Buffer _ = binary.Write(&buf, binary.LittleEndian, frd.Format) diff --git a/hotline/file_transfer.go b/hotline/file_transfer.go index 4e71bd6..386b24a 100644 --- a/hotline/file_transfer.go +++ b/hotline/file_transfer.go @@ -2,6 +2,7 @@ package hotline import ( "bufio" + "bytes" "crypto/rand" "encoding/binary" "errors" @@ -11,6 +12,7 @@ import ( "log/slog" "math" "os" + "path" "path/filepath" "slices" "strings" @@ -170,37 +172,44 @@ type folderUpload struct { FileNamePath []byte } -//func (fu *folderUpload) Write(p []byte) (int, error) { -// if len(p) < 7 { -// return 0, errors.New("buflen too short") -// } -// copy(fu.DataSize[:], p[0:2]) -// copy(fu.IsFolder[:], p[2:4]) -// copy(fu.PathItemCount[:], p[4:6]) -// -// fu.FileNamePath = make([]byte, binary.BigEndian.Uint16(fu.DataSize[:])-4) // -4 to subtract the path separator bytes TODO: wat -// n, err := io.ReadFull(rwc, fu.FileNamePath) -// if err != nil { -// return 0, err -// } -// -// return n + 6, nil -//} +// pathSegmentScanner implements bufio.SplitFunc for parsing path segments +func pathSegmentScanner(data []byte, _ bool) (advance int, token []byte, err error) { + if len(data) < 3 { + return 0, nil, nil + } + + segLen := int(data[2]) + totalLen := 3 + segLen + + if len(data) < totalLen { + return 0, nil, nil + } + + return totalLen, data[0:totalLen], nil +} func (fu *folderUpload) FormattedPath() string { pathItemLen := binary.BigEndian.Uint16(fu.PathItemCount[:]) + if pathItemLen == 0 { + return "" + } + var pathSegments []string - pathData := fu.FileNamePath + scanner := bufio.NewScanner(bytes.NewReader(fu.FileNamePath)) + scanner.Split(pathSegmentScanner) - // TODO: implement scanner interface instead? - for i := uint16(0); i < pathItemLen; i++ { - segLen := pathData[2] - pathSegments = append(pathSegments, string(pathData[3:3+segLen])) - pathData = pathData[3+segLen:] + for scanner.Scan() && len(pathSegments) < int(pathItemLen) { + segmentData := scanner.Bytes() + if len(segmentData) >= 3 { + segLen := int(segmentData[2]) + if len(segmentData) >= 3+segLen { + pathSegments = append(pathSegments, string(segmentData[3:3+segLen])) + } + } } - return filepath.Join(pathSegments...) + return path.Join(pathSegments...) } type FileHeader struct { @@ -243,18 +252,12 @@ func (fh *FileHeader) Read(p []byte) (int, error) { } func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, fs FileStore, rLogger *slog.Logger, preserveForks bool) error { - //s.Stats.DownloadCounter += 1 - //s.Stats.DownloadsInProgress += 1 - //defer func() { - // s.Stats.DownloadsInProgress -= 1 - //}() - var dataOffset int64 if fileTransfer.FileResumeData != nil { dataOffset = int64(binary.BigEndian.Uint32(fileTransfer.FileResumeData.ForkInfoList[0].DataSize[:])) } - fw, err := NewFileWrapper(fs, fullPath, 0) + hlFile, err := NewFile(fs, fullPath, 0) if err != nil { return fmt.Errorf("reading file header: %v", err) } @@ -264,12 +267,12 @@ func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, f // If file transfer options are included, that means this is a "quick preview" request. In this case skip sending // the flat file info and proceed directly to sending the file data. if fileTransfer.Options == nil { - if _, err = io.Copy(w, fw.Ffo); err != nil { + if _, err = io.Copy(w, hlFile.Ffo); err != nil { return fmt.Errorf("send flat file object: %v", err) } } - file, err := fw.dataForkReader() + file, err := hlFile.dataForkReader() if err != nil { return fmt.Errorf("open data fork reader: %v", err) } @@ -285,13 +288,13 @@ func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, f // If the client requested to resume transfer, do not send the resource fork header. if fileTransfer.FileResumeData == nil { - err = binary.Write(w, binary.BigEndian, fw.rsrcForkHeader()) + err = binary.Write(w, binary.BigEndian, hlFile.rsrcForkHeader()) if err != nil { return fmt.Errorf("send resource fork header: %v", err) } } - rFile, _ := fw.rsrcForkFile() + rFile, _ := hlFile.rsrcForkFile() //if err != nil { // // return fmt.Errorf("open resource fork file: %v", err) //} @@ -318,17 +321,18 @@ func UploadHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileTransfe if err == nil { // return fmt.Errorf("existing file found: %s", fullPath) } - // if errors.Is(err, fs.ErrNotExist) { - // If not found, open or create a new .incomplete file - file, err = os.OpenFile(fullPath+IncompleteFileSuffix, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return err - } - // } - defer file.Close() + if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("check file existence: %w", err) + } + + // If not found, open or create a new .incomplete file + file, err = os.OpenFile(fullPath+IncompleteFileSuffix, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("open temp file for uploade: %w", err) + } - f, err := NewFileWrapper(fileStore, fullPath, 0) + f, err := NewFile(fileStore, fullPath, 0) if err != nil { return err } @@ -350,9 +354,16 @@ func UploadHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileTransfe } if err := receiveFile(rwc, file, rForkWriter, iForkWriter, fileTransfer.bytesSentCounter); err != nil { + _ = file.Close() // Close on error return fmt.Errorf("receive file: %v", err) } + // Close the file before attempting to rename it. + if err := file.Close(); err != nil { + return fmt.Errorf("close file: %v", err) + } + + // Rename the temporary upload file to the final file name. if err := fileStore.Rename(fullPath+".incomplete", fullPath); err != nil { return fmt.Errorf("rename incomplete file: %v", err) } @@ -413,7 +424,7 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil return nil } - hlFile, err := NewFileWrapper(fileStore, path, 0) + hlFile, err := NewFile(fileStore, path, 0) if err != nil { return err } @@ -486,7 +497,6 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil return fmt.Errorf("error opening file: %w", err) } - // wr := bufio.NewWriterSize(rwc, 1460) if _, err = io.Copy(rwc, io.TeeReader(file, fileTransfer.bytesSentCounter)); err != nil { return fmt.Errorf("error sending file: %w", err) } @@ -542,7 +552,6 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT //s.Stats.UploadCounter += 1 var fu folderUpload - // TODO: implement io.Writer on folderUpload and replace this if _, err := io.ReadFull(rwc, fu.DataSize[:]); err != nil { return err } @@ -552,14 +561,14 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT if _, err := io.ReadFull(rwc, fu.PathItemCount[:]); err != nil { return err } - fu.FileNamePath = make([]byte, binary.BigEndian.Uint16(fu.DataSize[:])-4) // -4 to subtract the path separator bytes TODO: wat + fu.FileNamePath = make([]byte, binary.BigEndian.Uint16(fu.DataSize[:])-4) // -4 to subtract the length of the DataSize and IsFolder fields if _, err := io.ReadFull(rwc, fu.FileNamePath); err != nil { return err } if fu.IsFolder == [2]byte{0, 1} { - if _, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath())); os.IsNotExist(err) { - if err := os.Mkdir(filepath.Join(fullPath, fu.FormattedPath()), 0777); err != nil { + if _, err := os.Stat(path.Join(fullPath, fu.FormattedPath())); os.IsNotExist(err) { + if err := os.Mkdir(path.Join(fullPath, fu.FormattedPath()), 0777); err != nil { return err } } @@ -572,7 +581,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT nextAction := DlFldrActionSendFile // Check if we have the full file already. If so, send dlFldrAction_NextFile to client to skip. - _, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath())) + _, err := os.Stat(path.Join(fullPath, fu.FormattedPath())) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } @@ -581,7 +590,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT } // Check if we have a partial file already. If so, send dlFldrAction_ResumeFile to client to resume upload. - incompleteFile, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath()+IncompleteFileSuffix)) + incompleteFile, err := os.Stat(path.Join(fullPath, fu.FormattedPath()+IncompleteFileSuffix)) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } @@ -634,9 +643,9 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT return err } - filePath := filepath.Join(fullPath, fu.FormattedPath()) + filePath := path.Join(fullPath, fu.FormattedPath()) - hlFile, err := NewFileWrapper(fileStore, filePath, 0) + hlFile, err := NewFile(fileStore, filePath, 0) if err != nil { return err } @@ -665,6 +674,12 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT return err } + // Close the file before attempting to rename it. + if err := incWriter.Close(); err != nil { + return fmt.Errorf("close file: %v", err) + } + + // Rename the temporary upload file to the final file name. if err := os.Rename(filePath+".incomplete", filePath); err != nil { return err } diff --git a/hotline/file_transfer_test.go b/hotline/file_transfer_test.go index 0b549f8..ba29910 100644 --- a/hotline/file_transfer_test.go +++ b/hotline/file_transfer_test.go @@ -175,3 +175,94 @@ func TestFileHeader_Payload(t *testing.T) { }) } } + +func Test_folderUpload_FormattedPath(t *testing.T) { + tests := []struct { + name string + pathItemCount [2]byte + fileNamePath []byte + want string + }{ + { + name: "empty path", + pathItemCount: [2]byte{0x00, 0x00}, + fileNamePath: []byte{}, + want: "", + }, + { + name: "single path segment", + pathItemCount: [2]byte{0x00, 0x01}, + fileNamePath: []byte{ + 0x00, 0x00, // path separator + 0x03, // segment length + 0x66, 0x6f, 0x6f, // "foo" + }, + want: "foo", + }, + { + name: "multiple path segments", + pathItemCount: [2]byte{0x00, 0x03}, + fileNamePath: []byte{ + 0x00, 0x00, // path separator + 0x04, // segment length + 0x68, 0x6f, 0x6d, 0x65, // "home" + 0x00, 0x00, // path separator + 0x04, // segment length + 0x75, 0x73, 0x65, 0x72, // "user" + 0x00, 0x00, // path separator + 0x09, // segment length + 0x64, 0x6f, 0x63, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, // "documents" + }, + want: "home/user/documents", + }, + { + name: "path with spaces", + pathItemCount: [2]byte{0x00, 0x02}, + fileNamePath: []byte{ + 0x00, 0x00, // path separator + 0x07, // segment length + 0x4d, 0x79, 0x20, 0x46, 0x69, 0x6c, 0x65, // "My File" + 0x00, 0x00, // path separator + 0x0d, // segment length (13 bytes) + 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x61, 0x6e, 0x74, 0x2e, 0x74, 0x78, 0x74, // "Important.txt" + }, + want: "My File/Important.txt", + }, + { + name: "single character segments", + pathItemCount: [2]byte{0x00, 0x03}, + fileNamePath: []byte{ + 0x00, 0x00, // path separator + 0x01, // segment length + 0x61, // "a" + 0x00, 0x00, // path separator + 0x01, // segment length + 0x62, // "b" + 0x00, 0x00, // path separator + 0x01, // segment length + 0x63, // "c" + }, + want: "a/b/c", + }, + { + name: "path with special characters", + pathItemCount: [2]byte{0x00, 0x01}, + fileNamePath: []byte{ + 0x00, 0x00, // path separator + 0x08, // segment length + 0x74, 0x65, 0x73, 0x74, 0x40, 0x24, 0x25, 0x26, // "test@$%&" + }, + want: "test@$%&", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fu := &folderUpload{ + PathItemCount: tt.pathItemCount, + FileNamePath: tt.fileNamePath, + } + got := fu.FormattedPath() + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/hotline/file_wrapper.go b/hotline/file_wrapper.go index b13e0dc..b80bc4b 100644 --- a/hotline/file_wrapper.go +++ b/hotline/file_wrapper.go @@ -17,8 +17,8 @@ const ( RsrcForkNameTemplate = ".rsrc_%s" // template string for resource fork filenames ) -// fileWrapper encapsulates the data, info, and resource forks of a Hotline file and provides methods to manage the files. -type fileWrapper struct { +// File encapsulates the data, info, and resource forks of a Hotline file and provides methods to manage the files. +type File struct { fs FileStore Name string // Name of the file path string // path to file directory @@ -30,10 +30,10 @@ type fileWrapper struct { Ffo *flattenedFileObject } -func NewFileWrapper(fs FileStore, path string, dataOffset int64) (*fileWrapper, error) { +func NewFile(fs FileStore, path string, dataOffset int64) (*File, error) { dir := filepath.Dir(path) fName := filepath.Base(path) - f := fileWrapper{ + f := File{ fs: fs, Name: fName, path: dir, @@ -54,7 +54,7 @@ func NewFileWrapper(fs FileStore, path string, dataOffset int64) (*fileWrapper, return &f, nil } -func (f *fileWrapper) TotalSize() []byte { +func (f *File) TotalSize() []byte { var s int64 size := make([]byte, 4) @@ -73,7 +73,7 @@ func (f *fileWrapper) TotalSize() []byte { return size } -func (f *fileWrapper) rsrcForkSize() (s [4]byte) { +func (f *File) rsrcForkSize() (s [4]byte) { info, err := f.fs.Stat(f.rsrcPath) if err != nil { return s @@ -83,26 +83,26 @@ func (f *fileWrapper) rsrcForkSize() (s [4]byte) { return s } -func (f *fileWrapper) rsrcForkHeader() FlatFileForkHeader { +func (f *File) rsrcForkHeader() FlatFileForkHeader { return FlatFileForkHeader{ - ForkType: [4]byte{0x4D, 0x41, 0x43, 0x52}, // "MACR" + ForkType: ForkTypeMACR, DataSize: f.rsrcForkSize(), } } -func (f *fileWrapper) incompleteDataName() string { +func (f *File) incompleteDataName() string { return f.Name + IncompleteFileSuffix } -func (f *fileWrapper) rsrcForkName() string { +func (f *File) rsrcForkName() string { return fmt.Sprintf(RsrcForkNameTemplate, f.Name) } -func (f *fileWrapper) infoForkName() string { +func (f *File) infoForkName() string { return fmt.Sprintf(InfoForkNameTemplate, f.Name) } -func (f *fileWrapper) rsrcForkWriter() (io.WriteCloser, error) { +func (f *File) rsrcForkWriter() (io.WriteCloser, error) { file, err := os.OpenFile(f.rsrcPath, os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return nil, err @@ -111,7 +111,7 @@ func (f *fileWrapper) rsrcForkWriter() (io.WriteCloser, error) { return file, nil } -func (f *fileWrapper) InfoForkWriter() (io.WriteCloser, error) { +func (f *File) InfoForkWriter() (io.WriteCloser, error) { file, err := os.OpenFile(f.infoPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { return nil, err @@ -120,7 +120,7 @@ func (f *fileWrapper) InfoForkWriter() (io.WriteCloser, error) { return file, nil } -func (f *fileWrapper) incFileWriter() (io.WriteCloser, error) { +func (f *File) incFileWriter() (io.WriteCloser, error) { file, err := os.OpenFile(f.incompletePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) if err != nil { return nil, err @@ -129,15 +129,19 @@ func (f *fileWrapper) incFileWriter() (io.WriteCloser, error) { return file, nil } -func (f *fileWrapper) dataForkReader() (io.Reader, error) { - return f.fs.Open(f.dataPath) +func (f *File) dataForkReader() (io.Reader, error) { + if _, err := f.fs.Stat(f.dataPath); err == nil { + return f.fs.Open(f.dataPath) + } + + return f.fs.Open(f.incompletePath) } -func (f *fileWrapper) rsrcForkFile() (*os.File, error) { +func (f *File) rsrcForkFile() (*os.File, error) { return f.fs.Open(f.rsrcPath) } -func (f *fileWrapper) DataFile() (os.FileInfo, error) { +func (f *File) DataFile() (os.FileInfo, error) { if fi, err := f.fs.Stat(f.dataPath); err == nil { return fi, nil } @@ -154,7 +158,7 @@ func (f *fileWrapper) DataFile() (os.FileInfo, error) { // * Resource fork starting with .rsrc_ // * Info fork starting with .info // During Move of the meta files, os.ErrNotExist is ignored as these files may legitimately not exist. -func (f *fileWrapper) Move(newPath string) error { +func (f *File) Move(newPath string) error { err := f.fs.Rename(f.dataPath, filepath.Join(newPath, f.Name)) if err != nil { return err @@ -179,7 +183,7 @@ func (f *fileWrapper) Move(newPath string) error { } // Delete a file and its associated metadata files if they exist -func (f *fileWrapper) Delete() error { +func (f *File) Delete() error { err := f.fs.RemoveAll(f.dataPath) if err != nil { return err @@ -203,7 +207,7 @@ func (f *fileWrapper) Delete() error { return nil } -func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) { +func (f *File) flattenedFileObject() (*flattenedFileObject, error) { dataSize := make([]byte, 4) mTime := [8]byte{} @@ -227,7 +231,7 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) { } f.Ffo.FlatFileHeader = FlatFileHeader{ - Format: [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP" + Format: FormatFILP, Version: [2]byte{0, 1}, ForkCount: [2]byte{0, 2}, } @@ -247,7 +251,7 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) { } } else { f.Ffo.FlatFileInformationFork = FlatFileInformationFork{ - Platform: [4]byte{0x41, 0x4D, 0x41, 0x43}, // "AMAC" TODO: Remove hardcode to support "AWIN" Platform (maybe?) + Platform: PlatformAMAC, // TODO: Remove hardcode to support "MWIN" Platform (maybe?) TypeSignature: [4]byte([]byte(ft.TypeCode)), CreatorSignature: [4]byte([]byte(ft.CreatorCode)), PlatformFlags: [4]byte{0, 0, 1, 0}, // TODO: What is this? @@ -263,12 +267,12 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) { } f.Ffo.FlatFileInformationForkHeader = FlatFileForkHeader{ - ForkType: [4]byte{0x49, 0x4E, 0x46, 0x4F}, // "INFO" + ForkType: ForkTypeINFO, DataSize: f.Ffo.FlatFileInformationFork.Size(), } f.Ffo.FlatFileDataForkHeader = FlatFileForkHeader{ - ForkType: [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA" + ForkType: ForkTypeDATA, DataSize: [4]byte{dataSize[0], dataSize[1], dataSize[2], dataSize[3]}, } f.Ffo.FlatFileResForkHeader = f.rsrcForkHeader() diff --git a/hotline/files.go b/hotline/files.go index a1db329..581b11c 100644 --- a/hotline/files.go +++ b/hotline/files.go @@ -12,7 +12,7 @@ import ( "strings" ) -func fileTypeFromFilename(filename string) fileType { +func FileTypeFromFilename(filename string) fileType { fileExt := strings.ToLower(filepath.Ext(filename)) ft, ok := fileTypes[fileExt] if ok { @@ -26,7 +26,7 @@ func fileTypeFromInfo(info fs.FileInfo) (ft fileType, err error) { ft.CreatorCode = "n/a " ft.TypeCode = "fldr" } else { - ft = fileTypeFromFilename(info.Name()) + ft = FileTypeFromFilename(info.Name()) } return ft, nil @@ -87,8 +87,8 @@ func GetFileNameList(path string, ignoreList []string) (fields []Field, err erro copy(fnwi.Creator[:], fileCreator) } else { binary.BigEndian.PutUint32(fnwi.FileSize[:], uint32(rFile.Size())) - copy(fnwi.Type[:], fileTypeFromFilename(rFile.Name()).TypeCode) - copy(fnwi.Creator[:], fileTypeFromFilename(rFile.Name()).CreatorCode) + copy(fnwi.Type[:], FileTypeFromFilename(rFile.Name()).TypeCode) + copy(fnwi.Creator[:], FileTypeFromFilename(rFile.Name()).CreatorCode) } } else if file.IsDir() { dir, err := os.ReadDir(filepath.Join(path, file.Name())) @@ -112,9 +112,9 @@ func GetFileNameList(path string, ignoreList []string) (fields []Field, err erro continue } - hlFile, err := NewFileWrapper(&OSFileStore{}, path+"/"+file.Name(), 0) + hlFile, err := NewFile(&OSFileStore{}, path+"/"+file.Name(), 0) if err != nil { - return nil, fmt.Errorf("NewFileWrapper: %w", err) + return nil, fmt.Errorf("NewFile: %w", err) } copy(fnwi.FileSize[:], hlFile.TotalSize()) diff --git a/hotline/files_test.go b/hotline/files_test.go index 0a7eb7b..9bed670 100644 --- a/hotline/files_test.go +++ b/hotline/files_test.go @@ -148,7 +148,7 @@ func TestCalcItemCount(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tempDir) + defer func() { _ = os.RemoveAll(tempDir) }() // Create the test directory structure if err := createTestDirStructure(tempDir, tt.structure); err != nil { diff --git a/hotline/flattened_file_object.go b/hotline/flattened_file_object.go index 99ce204..7c0cd7d 100644 --- a/hotline/flattened_file_object.go +++ b/hotline/flattened_file_object.go @@ -45,7 +45,7 @@ type FlatFileInformationFork struct { func NewFlatFileInformationFork(fileName string, modifyTime [8]byte, typeSignature string, creatorSignature string) FlatFileInformationFork { return FlatFileInformationFork{ - Platform: [4]byte{0x41, 0x4D, 0x41, 0x43}, // "AMAC" TODO: Remove hardcode to support "AWIN" Platform (maybe?) + Platform: PlatformAMAC, // TODO: Remove hardcode to support "MWIN" Platform (maybe?) TypeSignature: [4]byte([]byte(typeSignature)), // TODO: Don't infer types from filename CreatorSignature: [4]byte([]byte(creatorSignature)), // TODO: Don't infer types from filename PlatformFlags: [4]byte{0, 0, 1, 0}, // TODO: What is this? @@ -128,7 +128,7 @@ func (ffif *FlatFileInformationFork) ReadNameSize() []byte { } type FlatFileForkHeader struct { - ForkType [4]byte // Either INFO, DATA or MACR + ForkType ForkType // Either INFO, DATA or MACR CompressionType [4]byte RSVD [4]byte DataSize [4]byte diff --git a/hotline/news.go b/hotline/news.go index a490a89..1ddeebd 100644 --- a/hotline/news.go +++ b/hotline/news.go @@ -14,7 +14,7 @@ var ( ) type ThreadedNewsMgr interface { - ListArticles(newsPath []string) NewsArtListData + ListArticles(newsPath []string) (NewsArtListData, error) GetArticle(newsPath []string, articleID uint32) *NewsArtData DeleteArticle(newsPath []string, articleID uint32, recursive bool) error PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error @@ -41,13 +41,13 @@ type NewsCategoryListData15 struct { readOffset int // Internal offset to track read progress } -func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { +func (newscat *NewsCategoryListData15) GetNewsArtListData() (NewsArtListData, error) { var newsArts []NewsArtList var newsArtsPayload []byte for i, art := range newscat.Articles { id := make([]byte, 4) - binary.BigEndian.PutUint32(id, i) + binary.BigEndian.PutUint32(id, i) // The article's map key in the Articles map is its ID. newsArts = append(newsArts, NewsArtList{ ID: [4]byte(id), @@ -70,8 +70,7 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { for _, v := range newsArts { b, err := io.ReadAll(&v) if err != nil { - // TODO - panic(err) + return NewsArtListData{}, err } newsArtsPayload = append(newsArtsPayload, b...) } @@ -81,7 +80,7 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { Name: []byte{}, Description: []byte{}, NewsArtList: newsArtsPayload, - } + }, nil } // NewsArtData represents an individual news article. @@ -157,8 +156,8 @@ type NewsArtList struct { } var ( - NewsFlavorLen = []byte{0x0a} - NewsFlavor = []byte("text/plain") + NewsFlavor = []byte("text/plain") // NewsFlavor is always "text/plain" + NewsFlavorCount = []byte{0, 1} // NewsFlavorCount is always 1 ) func (nal *NewsArtList) Read(p []byte) (int, error) { @@ -167,12 +166,12 @@ func (nal *NewsArtList) Read(p []byte) (int, error) { nal.TimeStamp[:], nal.ParentID[:], nal.Flags[:], - []byte{0, 1}, // Flavor Count TODO: make this not hardcoded + NewsFlavorCount, []byte{uint8(len(nal.Title))}, nal.Title, []byte{uint8(len(nal.Poster))}, nal.Poster, - NewsFlavorLen, + []byte{uint8(len(NewsFlavor))}, NewsFlavor, nal.ArticleSize[:], ) @@ -184,7 +183,7 @@ func (nal *NewsArtList) Read(p []byte) (int, error) { n := copy(p, out[nal.readOffset:]) nal.readOffset += n - return n, io.EOF + return n, nil } type NewsFlavorList struct { @@ -242,10 +241,10 @@ type MockThreadNewsMgr struct { mock.Mock } -func (m *MockThreadNewsMgr) ListArticles(newsPath []string) NewsArtListData { +func (m *MockThreadNewsMgr) ListArticles(newsPath []string) (NewsArtListData, error) { args := m.Called(newsPath) - return args.Get(0).(NewsArtListData) + return args.Get(0).(NewsArtListData), args.Error(1) } func (m *MockThreadNewsMgr) GetArticle(newsPath []string, articleID uint32) *NewsArtData { diff --git a/hotline/news_test.go b/hotline/news_test.go index d1b043e..46df2e3 100644 --- a/hotline/news_test.go +++ b/hotline/news_test.go @@ -97,3 +97,274 @@ func TestNewsCategoryListData15_MarshalBinary(t *testing.T) { }) } } + +func TestNewsCategoryListData15_GetNewsArtListData(t *testing.T) { + tests := []struct { + name string + newscat NewsCategoryListData15 + wantData NewsArtListData + wantErr bool + }{ + { + name: "empty articles", + newscat: NewsCategoryListData15{ + Articles: map[uint32]*NewsArtData{}, + }, + wantData: NewsArtListData{ + Count: 0, + Name: []byte{}, + Description: []byte{}, + NewsArtList: []byte{}, + }, + wantErr: false, + }, + { + name: "single article", + newscat: NewsCategoryListData15{ + Articles: map[uint32]*NewsArtData{ + 1: { + Title: "Test Title", + Poster: "Test Poster", + Date: [8]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}, + Data: "Test content", + }, + }, + }, + wantData: NewsArtListData{ + Count: 1, + Name: []byte{}, + Description: []byte{}, + }, + wantErr: false, + }, + { + name: "multiple articles", + newscat: NewsCategoryListData15{ + Articles: map[uint32]*NewsArtData{ + 2: { + Title: "Second Article", + Poster: "Author2", + Date: [8]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x08}, + Data: "Second content", + }, + 1: { + Title: "First Article", + Poster: "Author1", + Date: [8]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}, + Data: "First content", + }, + }, + }, + wantData: NewsArtListData{ + Count: 2, + Name: []byte{}, + Description: []byte{}, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotData, err := tt.newscat.GetNewsArtListData() + if (err != nil) != tt.wantErr { + t.Errorf("GetNewsArtListData() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Equal(t, tt.wantData.Count, gotData.Count) + assert.Equal(t, tt.wantData.Name, gotData.Name) + assert.Equal(t, tt.wantData.Description, gotData.Description) + if tt.wantData.Count > 0 { + assert.NotEmpty(t, gotData.NewsArtList) + } + }) + } +} + +func TestNewsArtData_DataSize(t *testing.T) { + tests := []struct { + name string + art NewsArtData + want [2]byte + }{ + { + name: "empty data", + art: NewsArtData{Data: ""}, + want: [2]byte{0x00, 0x00}, + }, + { + name: "short data", + art: NewsArtData{Data: "hello"}, + want: [2]byte{0x00, 0x05}, + }, + { + name: "longer data", + art: NewsArtData{Data: "This is a longer test message with more content"}, + want: [2]byte{0x00, 0x2F}, // 47 bytes + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.art.DataSize() + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNewsArtListData_Read(t *testing.T) { + tests := []struct { + name string + nald NewsArtListData + bufferSize int + wantN int + wantErr bool + }{ + { + name: "empty data", + nald: NewsArtListData{ + ID: [4]byte{0x00, 0x01, 0x02, 0x03}, + Name: []byte("test"), + Description: []byte("desc"), + NewsArtList: []byte{}, + Count: 0, + }, + bufferSize: 100, + wantN: 18, // 4 (ID) + 4 (count) + 1 (name len) + 4 (name) + 1 (desc len) + 4 (desc) + 0 (news art list) + wantErr: false, + }, + { + name: "with article list", + nald: NewsArtListData{ + ID: [4]byte{0x00, 0x01, 0x02, 0x03}, + Name: []byte("test"), + Description: []byte("desc"), + NewsArtList: []byte{0x01, 0x02, 0x03}, + Count: 1, + }, + bufferSize: 100, + wantN: 21, // 4 (ID) + 4 (count) + 1 (name len) + 4 (name) + 1 (desc len) + 4 (desc) + 3 (news art list) + wantErr: false, + }, + { + name: "small buffer", + nald: NewsArtListData{ + ID: [4]byte{0x00, 0x01, 0x02, 0x03}, + Name: []byte("test"), + Description: []byte("desc"), + Count: 0, + }, + bufferSize: 5, + wantN: 5, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := make([]byte, tt.bufferSize) + gotN, err := tt.nald.Read(p) + if (err != nil) != tt.wantErr { + t.Errorf("Read() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Equal(t, tt.wantN, gotN) + }) + } +} + +func TestNewsArtList_Read(t *testing.T) { + tests := []struct { + name string + nal NewsArtList + bufferSize int + wantN int + wantErr bool + }{ + { + name: "basic article", + nal: NewsArtList{ + ID: [4]byte{0x00, 0x01, 0x02, 0x03}, + TimeStamp: [8]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}, + ParentID: [4]byte{0x00, 0x00, 0x00, 0x00}, + Flags: [4]byte{0x00, 0x00, 0x00, 0x00}, + Title: []byte("Test Title"), + Poster: []byte("Test Poster"), + ArticleSize: [2]byte{0x00, 0x0A}, + }, + bufferSize: 100, + wantN: 58, // 4 (ID) + 8 (timestamp) + 4 (parent) + 4 (flags) + 2 (flavor count) + 1 (title len) + 10 (title) + 1 (poster len) + 11 (poster) + 1 (flavor len) + 10 (flavor) + 2 (article size) + wantErr: false, + }, + { + name: "small buffer", + nal: NewsArtList{ + ID: [4]byte{0x00, 0x01, 0x02, 0x03}, + TimeStamp: [8]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}, + Title: []byte("Test"), + Poster: []byte("Author"), + }, + bufferSize: 10, + wantN: 10, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := make([]byte, tt.bufferSize) + gotN, err := tt.nal.Read(p) + if (err != nil) != tt.wantErr { + t.Errorf("Read() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Equal(t, tt.wantN, gotN) + }) + } +} + +func TestNewsPathScanner(t *testing.T) { + tests := []struct { + name string + data []byte + wantAdvance int + wantToken []byte + wantErr bool + }{ + { + name: "insufficient data", + data: []byte{0x00, 0x01}, + wantAdvance: 0, + wantToken: nil, + wantErr: false, + }, + { + name: "valid token", + data: []byte{0x00, 0x01, 0x04, 0x74, 0x65, 0x73, 0x74}, // length 4, "test" + wantAdvance: 7, + wantToken: []byte("test"), + wantErr: false, + }, + { + name: "zero length token", + data: []byte{0x00, 0x01, 0x00}, + wantAdvance: 3, + wantToken: []byte{}, + wantErr: false, + }, + { + name: "single character token", + data: []byte{0x00, 0x01, 0x01, 0x61}, // length 1, "a" + wantAdvance: 4, + wantToken: []byte("a"), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotAdvance, gotToken, err := newsPathScanner(tt.data, false) + if (err != nil) != tt.wantErr { + t.Errorf("newsPathScanner() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Equal(t, tt.wantAdvance, gotAdvance) + assert.Equal(t, tt.wantToken, gotToken) + }) + } +} diff --git a/hotline/server.go b/hotline/server.go index a521a6b..9b74d99 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -5,11 +5,10 @@ import ( "bytes" "context" "crypto/rand" + "crypto/tls" "encoding/binary" "errors" "fmt" - "golang.org/x/text/encoding/charmap" - "golang.org/x/time/rate" "io" "log" "log/slog" @@ -18,6 +17,10 @@ import ( "strings" "sync" "time" + + "github.com/redis/go-redis/v9" + "golang.org/x/text/encoding/charmap" + "golang.org/x/time/rate" ) type contextKey string @@ -38,7 +41,8 @@ type Server struct { NetInterface string Port int - rateLimiters map[string]*rate.Limiter + rateLimiters map[string]*rate.Limiter + rateLimitersMu sync.Mutex handlers map[TranType]HandlerFunc @@ -64,6 +68,14 @@ type Server struct { BanList BanMgr MessageBoard io.ReadWriteSeeker + + Redis *redis.Client + + // TrackerRegistrar handles tracker registration (injectable for testing) + TrackerRegistrar TrackerRegistrar + + TLSConfig *tls.Config + TLSPort int } type Option = func(s *Server) @@ -94,19 +106,35 @@ func WithInterface(netInterface string) func(s *Server) { } } +// WithTrackerRegistrar optionally sets a custom tracker registrar (useful for testing). +func WithTrackerRegistrar(registrar TrackerRegistrar) func(s *Server) { + return func(s *Server) { + s.TrackerRegistrar = registrar + } +} + +// WithTLS optionally enables TLS support on the specified port. +func WithTLS(tlsConfig *tls.Config, port int) func(s *Server) { + return func(s *Server) { + s.TLSConfig = tlsConfig + s.TLSPort = port + } +} + type ServerConfig struct { } func NewServer(options ...Option) (*Server, error) { server := Server{ - handlers: make(map[TranType]HandlerFunc), - outbox: make(chan Transaction), - rateLimiters: make(map[string]*rate.Limiter), - FS: &OSFileStore{}, - ChatMgr: NewMemChatManager(), - ClientMgr: NewMemClientMgr(), - FileTransferMgr: NewMemFileTransferMgr(), - Stats: NewStats(), + handlers: make(map[TranType]HandlerFunc), + outbox: make(chan Transaction), + rateLimiters: make(map[string]*rate.Limiter), + FS: &OSFileStore{}, + ChatMgr: NewMemChatManager(), + ClientMgr: NewMemClientMgr(), + FileTransferMgr: NewMemFileTransferMgr(), + Stats: NewStats(), + TrackerRegistrar: NewRealTrackerRegistrar(), } for _, opt := range options { @@ -153,6 +181,28 @@ func (s *Server) ListenAndServe(ctx context.Context) error { log.Fatal(s.ServeFileTransfers(ctx, ln)) }() + if s.TLSConfig != nil { + wg.Add(1) + go func() { + ln, err := net.Listen("tcp", fmt.Sprintf("%s:%v", s.NetInterface, s.TLSPort)) + if err != nil { + log.Fatal(err) + } + + log.Fatal(s.ServeWithTLS(ctx, ln)) + }() + + wg.Add(1) + go func() { + ln, err := net.Listen("tcp", fmt.Sprintf("%s:%v", s.NetInterface, s.TLSPort+1)) + if err != nil { + log.Fatal(err) + } + + log.Fatal(s.ServeFileTransfersWithTLS(ctx, ln)) + }() + } + wg.Wait() return nil @@ -180,6 +230,14 @@ func (s *Server) ServeFileTransfers(ctx context.Context, ln net.Listener) error } } +func (s *Server) ServeWithTLS(ctx context.Context, ln net.Listener) error { + return s.Serve(ctx, tls.NewListener(ln, s.TLSConfig)) +} + +func (s *Server) ServeFileTransfersWithTLS(ctx context.Context, ln net.Listener) error { + return s.ServeFileTransfers(ctx, tls.NewListener(ln, s.TLSConfig)) +} + func (s *Server) sendTransaction(t Transaction) error { client := s.ClientMgr.Get(t.ClientID) @@ -224,26 +282,28 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error { } go func() { - ipAddr := strings.Split(conn.RemoteAddr().(*net.TCPAddr).String(), ":")[0] + ipAddr, _, _ := net.SplitHostPort(conn.RemoteAddr().String()) connCtx := context.WithValue(ctx, contextKeyReq, requestCtx{ remoteAddr: conn.RemoteAddr().String(), }) s.Logger.Info("Connection established", "ip", ipAddr) - defer conn.Close() + defer func() { _ = conn.Close() }() // Check if we have an existing rate limit for the IP and create one if we do not. + s.rateLimitersMu.Lock() rl, ok := s.rateLimiters[ipAddr] if !ok { rl = rate.NewLimiter(perIPRateLimit, 1) s.rateLimiters[ipAddr] = rl } + s.rateLimitersMu.Unlock() // Check if the rate limit is exceeded and close the connection if so. if !rl.Allow() { s.Logger.Info("Rate limit exceeded", "RemoteAddr", conn.RemoteAddr()) - conn.Close() + _ = conn.Close() return } @@ -262,6 +322,62 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error { // time in seconds between tracker re-registration const trackerUpdateFrequency = 300 +// TrackerRegistrar interface for tracker registration operations +type TrackerRegistrar interface { + Register(tracker string, registration *TrackerRegistration) error +} + +// RealTrackerRegistrar implements TrackerRegistrar using the real network operations +type RealTrackerRegistrar struct { + dialer Dialer +} + +func NewRealTrackerRegistrar() *RealTrackerRegistrar { + return &RealTrackerRegistrar{ + dialer: &RealDialer{}, + } +} + +func (r *RealTrackerRegistrar) Register(tracker string, registration *TrackerRegistration) error { + return register(r.dialer, tracker, registration) +} + +// parseTrackerPassword extracts the password from a tracker address in format "host:port:password" +// Returns empty string if no password is present or if the format is invalid +// For addresses with more than 3 parts (like passwords containing colons), everything after the second colon is treated as the password +func parseTrackerPassword(trackerAddr string) string { + splitAddr := strings.Split(trackerAddr, ":") + if len(splitAddr) >= 3 { + // Join everything from the third part onwards (index 2+) to handle passwords with colons + return strings.Join(splitAddr[2:], ":") + } + return "" +} + +// registerWithAllTrackers performs tracker registration for all configured trackers +func (s *Server) registerWithAllTrackers() { + if !s.Config.EnableTrackerRegistration { + return + } + + for _, t := range s.Config.Trackers { + tr := &TrackerRegistration{ + UserCount: len(s.ClientMgr.List()), + PassID: s.TrackerPassID, + Name: s.Config.Name, + Description: s.Config.Description, + } + binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port)) + binary.BigEndian.PutUint16(tr.TLSPort[:], uint16(s.TLSPort)) + + tr.Password = parseTrackerPassword(t) + + if err := s.TrackerRegistrar.Register(t, tr); err != nil { + s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err) + } + } +} + // registerWithTrackers runs every trackerUpdateFrequency seconds to update the server's tracker entry on all configured // trackers. func (s *Server) registerWithTrackers(ctx context.Context) { @@ -269,33 +385,19 @@ func (s *Server) registerWithTrackers(ctx context.Context) { s.Logger.Info("Tracker registration enabled", "trackers", s.Config.Trackers) } - for { - if s.Config.EnableTrackerRegistration { - for _, t := range s.Config.Trackers { - tr := &TrackerRegistration{ - UserCount: len(s.ClientMgr.List()), - PassID: s.TrackerPassID, - Name: s.Config.Name, - Description: s.Config.Description, - } - binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port)) + // Do the first registration immediately + s.registerWithAllTrackers() - // Check the tracker string for a password. This is janky but avoids a breaking change to the Config - // Trackers field. - splitAddr := strings.Split(":", t) - if len(splitAddr) == 3 { - tr.Password = splitAddr[2] - } + ticker := time.NewTicker(trackerUpdateFrequency * time.Second) + defer ticker.Stop() - if err := register(&RealDialer{}, t, tr); err != nil { - s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err) - } - } + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.registerWithAllTrackers() } - // Using time.Ticker with for/select would be more idiomatic, but it's super annoying that it doesn't tick on - // first pass. Revist, maybe. - // https://github.com/golang/go/issues/17601 - time.Sleep(trackerUpdateFrequency * time.Second) } } @@ -372,24 +474,6 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser return fmt.Errorf("perform handshake: %w", err) } - // Check if remoteAddr is present in the ban list - ipAddr := strings.Split(remoteAddr, ":")[0] - if isBanned, banUntil := s.BanList.IsBanned(ipAddr); isBanned { - // permaban - if banUntil == nil { - sendBanMessage(rwc, "You are permanently banned on this server") - s.Logger.Debug("Disconnecting permanently banned IP", "remoteAddr", ipAddr) - return nil - } - - // temporary ban - if time.Now().Before(*banUntil) { - sendBanMessage(rwc, "You are temporarily banned on this server") - s.Logger.Debug("Disconnecting temporarily banned IP", "remoteAddr", ipAddr) - return nil - } - } - // Create a new scanner for parsing incoming bytes into transaction tokens scanner := bufio.NewScanner(rwc) scanner.Split(transactionScanner) @@ -406,17 +490,66 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser return fmt.Errorf("error writing login transaction: %w", err) } - c := s.NewClientConn(rwc, remoteAddr) - defer c.Disconnect() - - encodedPassword := clientLogin.GetField(FieldUserPassword).Data - c.Version = clientLogin.GetField(FieldVersion).Data - login := clientLogin.GetField(FieldUserLogin).DecodeObfuscatedString() if login == "" { login = GuestAccount } + // Check if remoteAddr is present in the ban list, we do this after we have the login name + ipAddr, _, _ := net.SplitHostPort(remoteAddr) + if s.Redis != nil { + // Redis-based ban check + bannedUser, _ := s.Redis.SIsMember(ctx, "mobius:banned:users", login).Result() + bannedIP, _ := s.Redis.SIsMember(ctx, "mobius:banned:ips", ipAddr).Result() + if bannedUser { + s.Redis.SAdd(ctx, "mobius:banned:ips", ipAddr) + sendBanMessage(rwc, "You are banned on this server") + s.Logger.Debug("Disconnecting banned user", "login", login, "ip", ipAddr) + return nil + } + if bannedIP { + sendBanMessage(rwc, "You are banned on this server") + s.Logger.Debug("Disconnecting banned IP", "ip", ipAddr) + return nil + } + } else { + // Fallback to in-memory ban list + if isBanned, banUntil := s.BanList.IsBanned(ipAddr); isBanned { + // permaban + if banUntil == nil { + sendBanMessage(rwc, "You are permanently banned on this server") + s.Logger.Debug("Disconnecting permanently banned IP", "remoteAddr", ipAddr) + return nil + } + // temporary ban + if time.Now().Before(*banUntil) { + sendBanMessage(rwc, "You are temporarily banned on this server") + s.Logger.Debug("Disconnecting temporarily banned IP", "remoteAddr", ipAddr) + return nil + } + } + } + + c := s.NewClientConn(rwc, remoteAddr) + // Add the client to the list of connected clients + if s.Redis != nil { + s.Redis.SAdd(context.Background(), "mobius:online", login+"::"+ipAddr) + } + + // Remove the client from the list of connected clients when they disconnect + defer func() { + if s.Redis != nil { + s.Redis.SRem(context.Background(), "mobius:online", login+"::"+ipAddr) + if len(c.UserName) != 0 { + s.Redis.SRem(context.Background(), "mobius:online", login+":"+string(c.UserName)+":"+ipAddr) + } + } + c.Disconnect() + }() + + encodedPassword := clientLogin.GetField(FieldUserPassword).Data + c.Version = clientLogin.GetField(FieldVersion).Data + c.Logger = s.Logger.With("ip", ipAddr, "login", login) // If authentication fails, send error reply and close connection @@ -486,6 +619,14 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser c.Logger = c.Logger.With("name", string(c.UserName)) c.Logger.Info("Login successful") + // Update the Redis set with the new information + if s.Redis != nil && len(c.UserName) != 0 { + // Remove old entry (login::ip) + s.Redis.SRem(context.Background(), "mobius:online", login+"::"+ipAddr) + // Add new entry with login, nickname, ip + s.Redis.SAdd(context.Background(), "mobius:online", login+":"+string(c.UserName)+":"+ipAddr) + } + // Notify other clients on the server that the new user has logged in. For 1.5+ clients we don't have this // information yet, so we do it in TranAgreed instead for _, t := range c.NotifyOthers( @@ -605,12 +746,12 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro var transferSizeValue uint32 switch len(fileTransfer.TransferSize) { - case 2: // 16-bit - transferSizeValue = uint32(binary.BigEndian.Uint16(fileTransfer.TransferSize)) - case 4: // 32-bit - transferSizeValue = binary.BigEndian.Uint32(fileTransfer.TransferSize) - default: - rLogger.Warn("Unexpected TransferSize length: %d bytes", len(fileTransfer.TransferSize)) + case 2: // 16-bit + transferSizeValue = uint32(binary.BigEndian.Uint16(fileTransfer.TransferSize)) + case 4: // 32-bit + transferSizeValue = binary.BigEndian.Uint32(fileTransfer.TransferSize) + default: + rLogger.Warn("Unexpected TransferSize length", "bytes", len(fileTransfer.TransferSize)) } rLogger.Info( diff --git a/hotline/server_test.go b/hotline/server_test.go index d0e2372..574aae7 100644 --- a/hotline/server_test.go +++ b/hotline/server_test.go @@ -3,12 +3,15 @@ package hotline import ( "bytes" "context" + "encoding/binary" "fmt" "github.com/stretchr/testify/assert" "io" "log/slog" "os" + "strings" "testing" + "time" ) type mockReadWriter struct { @@ -181,3 +184,583 @@ func TestServer_handleFileTransfer(t *testing.T) { }) } } + +func TestParseTrackerPassword(t *testing.T) { + tests := []struct { + name string + trackerAddr string + wantPassword string + }{ + { + name: "tracker address with password", + trackerAddr: "tracker.example.com:5500:mypassword", + wantPassword: "mypassword", + }, + { + name: "tracker address without password", + trackerAddr: "tracker.example.com:5500", + wantPassword: "", + }, + { + name: "tracker address with empty password", + trackerAddr: "tracker.example.com:5500:", + wantPassword: "", + }, + { + name: "tracker address with password containing special characters", + trackerAddr: "tracker.example.com:5500:pass@word#123", + wantPassword: "pass@word#123", + }, + { + name: "tracker address with password containing colons", + trackerAddr: "tracker.example.com:5500:pass:word:123", + wantPassword: "pass:word:123", + }, + { + name: "IPv4 address with password", + trackerAddr: "192.168.1.100:5500:secret", + wantPassword: "secret", + }, + { + name: "IPv4 address without password", + trackerAddr: "192.168.1.100:5500", + wantPassword: "", + }, + { + name: "malformed address - no port", + trackerAddr: "tracker.example.com", + wantPassword: "", + }, + { + name: "malformed address - empty string", + trackerAddr: "", + wantPassword: "", + }, + { + name: "malformed address - only colons", + trackerAddr: ":::", + wantPassword: ":", + }, + { + name: "IPv6 address handling (edge case - not properly supported)", + trackerAddr: "[::1]:5500:password", + wantPassword: "1]:5500:password", // IPv6 addresses aren't properly handled by simple colon splitting + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTrackerPassword(tt.trackerAddr) + assert.Equal(t, tt.wantPassword, got) + }) + } +} + +// MockTrackerRegistrar is a mock implementation of TrackerRegistrar for testing +type MockTrackerRegistrar struct { + RegisterCalls []RegisterCall + RegisterFunc func(tracker string, registration *TrackerRegistration) error +} + +type RegisterCall struct { + Tracker string + Registration *TrackerRegistration +} + +func (m *MockTrackerRegistrar) Register(tracker string, registration *TrackerRegistration) error { + // Record the call + m.RegisterCalls = append(m.RegisterCalls, RegisterCall{ + Tracker: tracker, + Registration: registration, + }) + + // Use custom function if provided, otherwise return nil (success) + if m.RegisterFunc != nil { + return m.RegisterFunc(tracker, registration) + } + return nil +} + +func (m *MockTrackerRegistrar) Reset() { + m.RegisterCalls = nil + m.RegisterFunc = nil +} + +func TestServer_registerWithTrackers(t *testing.T) { + tests := []struct { + name string + config Config + wantImmediateRegistration bool + wantTrackerCalls []string + mockRegisterFunc func(tracker string, registration *TrackerRegistration) error + expectError bool + }{ + { + name: "disabled tracker registration", + config: Config{ + EnableTrackerRegistration: false, + Trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + }, + wantImmediateRegistration: false, + wantTrackerCalls: []string{}, + }, + { + name: "enabled tracker registration with multiple trackers", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + Name: "Test Server", + Description: "Test Description", + }, + wantImmediateRegistration: true, + wantTrackerCalls: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + }, + { + name: "enabled tracker registration with empty tracker list", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{}, + Name: "Test Server", + Description: "Test Description", + }, + wantImmediateRegistration: true, + wantTrackerCalls: []string{}, + }, + { + name: "tracker registration with network errors", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker1.example.com:5500"}, + Name: "Test Server", + Description: "Test Description", + }, + wantImmediateRegistration: true, + wantTrackerCalls: []string{"tracker1.example.com:5500"}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + return assert.AnError // Simulate network error + }, + expectError: false, // Errors are logged but don't stop the function + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock registrar + mockRegistrar := &MockTrackerRegistrar{ + RegisterFunc: tt.mockRegisterFunc, + } + + // Create server with mock registrar + server, err := NewServer( + WithConfig(tt.config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + // Create a context that we can cancel + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start the registerWithTrackers function in a goroutine + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + // Give it a moment to do the immediate registration + time.Sleep(100 * time.Millisecond) + + // Cancel the context to stop the goroutine + cancel() + + // Wait for the goroutine to finish (should be quick after cancellation) + select { + case <-done: + // Success + case <-time.After(1 * time.Second): + t.Fatal("registerWithTrackers did not exit after context cancellation") + } + + // Verify the calls made to the mock registrar + assert.Len(t, mockRegistrar.RegisterCalls, len(tt.wantTrackerCalls)) + + for i, expectedTracker := range tt.wantTrackerCalls { + if i < len(mockRegistrar.RegisterCalls) { + call := mockRegistrar.RegisterCalls[i] + assert.Equal(t, expectedTracker, call.Tracker) + assert.Equal(t, tt.config.Name, call.Registration.Name) + assert.Equal(t, tt.config.Description, call.Registration.Description) + assert.Equal(t, parseTrackerPassword(expectedTracker), call.Registration.Password) + } + } + }) + } +} + +func TestServer_registerWithTrackers_ContextCancellation(t *testing.T) { + tests := []struct { + name string + cancelAfter time.Duration + expectedCalls int // Number of expected registration calls before cancellation + trackerCount int + }{ + { + name: "immediate cancellation", + cancelAfter: 10 * time.Millisecond, + expectedCalls: 2, // Should complete immediate registration + trackerCount: 2, + }, + { + name: "cancellation after first ticker", + cancelAfter: 100 * time.Millisecond, + expectedCalls: 2, // Should only do immediate registration within 100ms + trackerCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRegistrar := &MockTrackerRegistrar{} + config := Config{ + EnableTrackerRegistration: true, + Trackers: make([]string, tt.trackerCount), + Name: "Test Server", + Description: "Test Description", + } + + // Fill trackers array + for i := 0; i < tt.trackerCount; i++ { + config.Trackers[i] = fmt.Sprintf("tracker%d.example.com:5500", i+1) + } + + server, err := NewServer( + WithConfig(config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + // Wait for the specified time then cancel + time.Sleep(tt.cancelAfter) + cancel() + + // Wait for graceful shutdown + select { + case <-done: + // Success + case <-time.After(1 * time.Second): + t.Fatal("registerWithTrackers did not exit after context cancellation") + } + + // Verify that the function respects context cancellation + assert.Equal(t, tt.expectedCalls, len(mockRegistrar.RegisterCalls)) + }) + } +} + +func TestServer_registerWithTrackers_PeriodicRegistration(t *testing.T) { + t.Skip("Skipping timing-sensitive test - would take 5+ minutes to run reliably") + + // This test would verify that periodic re-registration happens every trackerUpdateFrequency seconds + // but it's impractical to run in normal test suites due to the 300-second interval + + mockRegistrar := &MockTrackerRegistrar{} + config := Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker1.example.com:5500"}, + Name: "Test Server", + Description: "Test Description", + } + + server, err := NewServer( + WithConfig(config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + // Wait for timeout or completion + <-ctx.Done() + + // Should have done immediate registration only (1 call) in 10 seconds + // since trackerUpdateFrequency is 300 seconds + assert.Equal(t, 1, len(mockRegistrar.RegisterCalls)) +} + +func TestServer_registerWithTrackers_ErrorHandling(t *testing.T) { + tests := []struct { + name string + trackers []string + mockRegisterFunc func(tracker string, registration *TrackerRegistration) error + expectPanic bool + }{ + { + name: "handles network errors gracefully", + trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + if tracker == "tracker1.example.com:5500" { + return fmt.Errorf("network error: connection refused") + } + return nil // Second tracker succeeds + }, + expectPanic: false, + }, + { + name: "handles all trackers failing", + trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500"}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + return fmt.Errorf("network error") + }, + expectPanic: false, + }, + { + name: "handles empty tracker addresses", + trackers: []string{"", "valid.tracker.com:5500", ""}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + if tracker == "" { + return fmt.Errorf("invalid tracker address") + } + return nil + }, + expectPanic: false, + }, + { + name: "handles malformed tracker addresses", + trackers: []string{"invalid-address", "another:invalid", "valid.tracker.com:5500:password"}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + return nil // Accept all for this test + }, + expectPanic: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRegistrar := &MockTrackerRegistrar{ + RegisterFunc: tt.mockRegisterFunc, + } + + config := Config{ + EnableTrackerRegistration: true, + Trackers: tt.trackers, + Name: "Test Server", + Description: "Test Description", + } + + server, err := NewServer( + WithConfig(config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if tt.expectPanic { + assert.Panics(t, func() { + server.registerWithTrackers(ctx) + }) + return + } + + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + // Give it time to process + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case <-done: + // Success - function completed without panicking + case <-time.After(1 * time.Second): + t.Fatal("registerWithTrackers did not exit after context cancellation") + } + + // Verify all trackers were attempted + assert.Equal(t, len(tt.trackers), len(mockRegistrar.RegisterCalls)) + }) + } +} + +func TestServer_registerWithTrackers_EdgeCases(t *testing.T) { + tests := []struct { + name string + config Config + expectedCalls int + validateResult func(t *testing.T, calls []RegisterCall) + }{ + { + name: "server with zero port", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker.example.com:5500"}, + Name: "Test Server", + Description: "Test Description", + }, + expectedCalls: 1, + validateResult: func(t *testing.T, calls []RegisterCall) { + assert.Equal(t, uint16(0), binary.BigEndian.Uint16(calls[0].Registration.Port[:])) + }, + }, + { + name: "server with very long name and description", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker.example.com:5500"}, + Name: strings.Repeat("A", 255), // Max uint8 length + Description: strings.Repeat("B", 255), + }, + expectedCalls: 1, + validateResult: func(t *testing.T, calls []RegisterCall) { + assert.Equal(t, strings.Repeat("A", 255), calls[0].Registration.Name) + assert.Equal(t, strings.Repeat("B", 255), calls[0].Registration.Description) + }, + }, + { + name: "empty server name and description", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker.example.com:5500"}, + Name: "", + Description: "", + }, + expectedCalls: 1, + validateResult: func(t *testing.T, calls []RegisterCall) { + assert.Equal(t, "", calls[0].Registration.Name) + assert.Equal(t, "", calls[0].Registration.Description) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRegistrar := &MockTrackerRegistrar{} + + server, err := NewServer( + WithConfig(tt.config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case <-done: + // Success + case <-time.After(1 * time.Second): + t.Fatal("registerWithTrackers did not exit after context cancellation") + } + + assert.Equal(t, tt.expectedCalls, len(mockRegistrar.RegisterCalls)) + if tt.validateResult != nil { + tt.validateResult(t, mockRegistrar.RegisterCalls) + } + }) + } +} + +func TestServer_registerWithAllTrackers(t *testing.T) { + tests := []struct { + name string + config Config + expectRegistrationAttempt bool + expectedTrackerCalls []string + }{ + { + name: "disabled tracker registration", + config: Config{ + EnableTrackerRegistration: false, + Trackers: []string{"tracker1.example.com:5500"}, + }, + expectRegistrationAttempt: false, + expectedTrackerCalls: []string{}, + }, + { + name: "enabled tracker registration with multiple trackers", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + Name: "Test Server", + Description: "Test Description", + }, + expectRegistrationAttempt: true, + expectedTrackerCalls: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + }, + { + name: "enabled tracker registration with empty tracker list", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{}, + Name: "Test Server", + Description: "Test Description", + }, + expectRegistrationAttempt: true, + expectedTrackerCalls: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRegistrar := &MockTrackerRegistrar{} + + server, err := NewServer( + WithConfig(tt.config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + // Call the extracted function directly + server.registerWithAllTrackers() + + // Verify the expected number of calls + assert.Equal(t, len(tt.expectedTrackerCalls), len(mockRegistrar.RegisterCalls)) + + // Verify each call + for i, expectedTracker := range tt.expectedTrackerCalls { + if i < len(mockRegistrar.RegisterCalls) { + call := mockRegistrar.RegisterCalls[i] + assert.Equal(t, expectedTracker, call.Tracker) + assert.Equal(t, tt.config.Name, call.Registration.Name) + assert.Equal(t, tt.config.Description, call.Registration.Description) + assert.Equal(t, parseTrackerPassword(expectedTracker), call.Registration.Password) + } + } + }) + } +} diff --git a/hotline/stats.go b/hotline/stats.go index 316a67d..9731601 100644 --- a/hotline/stats.go +++ b/hotline/stats.go @@ -61,7 +61,9 @@ func (s *Stats) Decrement(key int) { s.mu.Lock() defer s.mu.Unlock() - s.stats[key]-- + if s.stats[key] > 0 { + s.stats[key]-- + } } func (s *Stats) Set(key, val int) { diff --git a/hotline/stats_test.go b/hotline/stats_test.go new file mode 100644 index 0000000..2174227 --- /dev/null +++ b/hotline/stats_test.go @@ -0,0 +1,293 @@ +package hotline + +import ( + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestStats_Increment(t *testing.T) { + tests := []struct { + name string + keys []int + expected map[int]int + }{ + { + name: "single key increment", + keys: []int{StatCurrentlyConnected}, + expected: map[int]int{ + StatCurrentlyConnected: 1, + }, + }, + { + name: "multiple keys increment", + keys: []int{StatCurrentlyConnected, StatDownloadCounter, StatUploadCounter}, + expected: map[int]int{ + StatCurrentlyConnected: 1, + StatDownloadCounter: 1, + StatUploadCounter: 1, + }, + }, + { + name: "duplicate keys increment", + keys: []int{StatCurrentlyConnected, StatCurrentlyConnected}, + expected: map[int]int{ + StatCurrentlyConnected: 2, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stats := NewStats() + stats.Increment(tt.keys...) + + for key, expectedVal := range tt.expected { + assert.Equal(t, expectedVal, stats.Get(key)) + } + }) + } +} + +func TestStats_Increment_Multiple_Calls(t *testing.T) { + stats := NewStats() + + stats.Increment(StatCurrentlyConnected) + assert.Equal(t, 1, stats.Get(StatCurrentlyConnected)) + + stats.Increment(StatCurrentlyConnected) + assert.Equal(t, 2, stats.Get(StatCurrentlyConnected)) + + stats.Increment(StatCurrentlyConnected, StatDownloadCounter) + assert.Equal(t, 3, stats.Get(StatCurrentlyConnected)) + assert.Equal(t, 1, stats.Get(StatDownloadCounter)) +} + +func TestStats_Decrement(t *testing.T) { + tests := []struct { + name string + setupValue int + key int + expected int + }{ + { + name: "decrement from positive value", + setupValue: 5, + key: StatCurrentlyConnected, + expected: 4, + }, + { + name: "decrement from zero stays zero", + setupValue: 0, + key: StatCurrentlyConnected, + expected: 0, + }, + { + name: "decrement from one", + setupValue: 1, + key: StatCurrentlyConnected, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stats := NewStats() + stats.Set(tt.key, tt.setupValue) + + stats.Decrement(tt.key) + + assert.Equal(t, tt.expected, stats.Get(tt.key)) + }) + } +} + +func TestStats_Decrement_Multiple_Calls(t *testing.T) { + stats := NewStats() + + stats.Set(StatCurrentlyConnected, 10) + assert.Equal(t, 10, stats.Get(StatCurrentlyConnected)) + + stats.Decrement(StatCurrentlyConnected) + assert.Equal(t, 9, stats.Get(StatCurrentlyConnected)) + + stats.Decrement(StatCurrentlyConnected) + assert.Equal(t, 8, stats.Get(StatCurrentlyConnected)) +} + +func TestStats_Set(t *testing.T) { + tests := []struct { + name string + key int + value int + expected int + }{ + { + name: "set positive value", + key: StatCurrentlyConnected, + value: 42, + expected: 42, + }, + { + name: "set zero value", + key: StatDownloadCounter, + value: 0, + expected: 0, + }, + { + name: "set negative value", + key: StatUploadCounter, + value: -5, + expected: -5, + }, + { + name: "overwrite existing value", + key: StatConnectionPeak, + value: 100, + expected: 100, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stats := NewStats() + + if tt.name == "overwrite existing value" { + stats.Set(tt.key, 50) + } + + stats.Set(tt.key, tt.value) + + assert.Equal(t, tt.expected, stats.Get(tt.key)) + }) + } +} + +func TestStats_Get(t *testing.T) { + tests := []struct { + name string + key int + setValue int + expected int + }{ + { + name: "get initialized value", + key: StatCurrentlyConnected, + setValue: 0, + expected: 0, + }, + { + name: "get set value", + key: StatDownloadCounter, + setValue: 25, + expected: 25, + }, + { + name: "get after increment", + key: StatUploadCounter, + setValue: -1, + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stats := NewStats() + + if tt.name == "get after increment" { + stats.Increment(tt.key) + } else { + stats.Set(tt.key, tt.setValue) + } + + result := stats.Get(tt.key) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestStats_Get_Default_Values(t *testing.T) { + stats := NewStats() + + expectedDefaults := map[int]int{ + StatCurrentlyConnected: 0, + StatDownloadsInProgress: 0, + StatUploadsInProgress: 0, + StatWaitingDownloads: 0, + StatConnectionPeak: 0, + StatDownloadCounter: 0, + StatUploadCounter: 0, + StatConnectionCounter: 0, + } + + for key, expected := range expectedDefaults { + assert.Equal(t, expected, stats.Get(key)) + } +} + +func TestStats_Values(t *testing.T) { + stats := NewStats() + + // Test default values + values := stats.Values() + + assert.Equal(t, 0, values["CurrentlyConnected"]) + assert.Equal(t, 0, values["DownloadsInProgress"]) + assert.Equal(t, 0, values["UploadsInProgress"]) + assert.Equal(t, 0, values["WaitingDownloads"]) + assert.Equal(t, 0, values["ConnectionPeak"]) + assert.Equal(t, 0, values["ConnectionCounter"]) + assert.Equal(t, 0, values["DownloadCounter"]) + assert.Equal(t, 0, values["UploadCounter"]) + assert.NotNil(t, values["Since"]) + + // Verify Since is a time.Time + _, ok := values["Since"].(time.Time) + assert.True(t, ok, "Since should be a time.Time") +} + +func TestStats_Values_WithModifiedStats(t *testing.T) { + stats := NewStats() + + // Modify some stats + stats.Set(StatCurrentlyConnected, 10) + stats.Set(StatDownloadsInProgress, 5) + stats.Increment(StatConnectionCounter) + stats.Increment(StatDownloadCounter, StatUploadCounter) + + values := stats.Values() + + assert.Equal(t, 10, values["CurrentlyConnected"]) + assert.Equal(t, 5, values["DownloadsInProgress"]) + assert.Equal(t, 0, values["UploadsInProgress"]) + assert.Equal(t, 0, values["WaitingDownloads"]) + assert.Equal(t, 0, values["ConnectionPeak"]) + assert.Equal(t, 1, values["ConnectionCounter"]) + assert.Equal(t, 1, values["DownloadCounter"]) + assert.Equal(t, 1, values["UploadCounter"]) +} + +func TestStats_Values_ContainsAllKeys(t *testing.T) { + stats := NewStats() + values := stats.Values() + + expectedKeys := []string{ + "CurrentlyConnected", + "DownloadsInProgress", + "UploadsInProgress", + "WaitingDownloads", + "ConnectionPeak", + "ConnectionCounter", + "DownloadCounter", + "UploadCounter", + "Since", + } + + for _, key := range expectedKeys { + _, exists := values[key] + assert.True(t, exists, "Key %s should exist in Values() output", key) + } + + // Should have exactly 9 keys + assert.Equal(t, 9, len(values)) +}
\ No newline at end of file diff --git a/hotline/tracker.go b/hotline/tracker.go index 52963bf..ee4fc05 100644 --- a/hotline/tracker.go +++ b/hotline/tracker.go @@ -15,6 +15,7 @@ import ( type TrackerRegistration struct { Port [2]byte // Server's listening TCP port number UserCount int // Number of users connected to this particular server + TLSPort [2]byte // TLSPort PassID [4]byte // Random number generated by the server Name string // Server Name Description string // Description of the server @@ -32,7 +33,7 @@ func (tr *TrackerRegistration) Read(p []byte) (int, error) { []byte{0x00, 0x01}, // Magic number, always 1 tr.Port[:], userCount, - []byte{0x00, 0x00}, // Magic number, always 0 + tr.TLSPort[:], tr.PassID[:], []byte{uint8(len(tr.Name))}, []byte(tr.Name), @@ -69,7 +70,7 @@ func register(dialer Dialer, tracker string, tr io.Reader) error { if err != nil { return fmt.Errorf("failed to dial tracker: %v", err) } - defer conn.Close() + defer func() { _ = conn.Close() }() if _, err := io.Copy(conn, tr); err != nil { return fmt.Errorf("failed to write to connection: %w", err) @@ -91,11 +92,22 @@ type TrackerHeader struct { Version [2]byte // Old protocol (1) or new (2) } +// ServerInfoHeader represents a batch header in the tracker response. +// The tracker protocol splits large server lists into batches, with each batch +// preceded by its own ServerInfoHeader. The first header indicates the total +// number of servers across all batches, and each header (including the first) +// indicates how many servers are in the current batch. +// +// Example flow for 106 servers split into batches: +// 1. First ServerInfoHeader: SrvCount=106, BatchSize=97 +// 2. Read 97 ServerRecords +// 3. Second ServerInfoHeader: SrvCount=106, BatchSize=9 +// 4. Read 9 ServerRecords (total: 106) type ServerInfoHeader struct { MsgType [2]byte // Always has value of 1 MsgDataSize [2]byte // Remaining size of request - SrvCount [2]byte // Number of servers in the server list - SrvCountDup [2]byte // Same as previous field ¯\_(ツ)_/¯ + SrvCount [2]byte // Total number of servers across all batches + BatchSize [2]byte // Number of servers in the current batch } // ServerRecord is a tracker listing for a single server @@ -103,7 +115,7 @@ type ServerRecord struct { IPAddr [4]byte Port [2]byte NumUsers [2]byte // Number of users connected to this particular server - Unused [2]byte + TLSPort [2]byte NameSize byte // Length of Name string Name []byte // Server Name DescriptionSize byte @@ -128,79 +140,92 @@ func GetListing(conn io.ReadWriteCloser) ([]ServerRecord, error) { return nil, err } + // Use a buffered reader so we can read both headers and server records from the same buffer + reader := bufio.NewReader(conn) + var info ServerInfoHeader - if err := binary.Read(conn, binary.BigEndian, &info); err != nil { + if err := binary.Read(reader, binary.BigEndian, &info); err != nil { return nil, err } totalSrv := int(binary.BigEndian.Uint16(info.SrvCount[:])) + batchSize := int(binary.BigEndian.Uint16(info.BatchSize[:])) - scanner := bufio.NewScanner(conn) - scanner.Split(serverScanner) + servers := make([]ServerRecord, 0, totalSrv) + serversInCurrentBatch := 0 - var servers []ServerRecord - for { - scanner.Scan() + for len(servers) < totalSrv { + // Check if we've read all servers in the current batch + if serversInCurrentBatch == batchSize { + // Read the next ServerInfoHeader for the next batch from the buffered reader + if err := binary.Read(reader, binary.BigEndian, &info); err != nil { + return nil, fmt.Errorf("failed to read next ServerInfoHeader after %d servers: %w", len(servers), err) + } - // Make a new []byte slice and copy the scanner bytes to it. This is critical as the - // scanner re-uses the buffer for subsequent scans. - buf := make([]byte, len(scanner.Bytes())) - copy(buf, scanner.Bytes()) + batchSize = int(binary.BigEndian.Uint16(info.BatchSize[:])) + serversInCurrentBatch = 0 + } - var srv ServerRecord - _, err = srv.Write(buf) + // Read a server record using our helper function + srv, err := readServerRecord(reader) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read server record %d: %w", len(servers)+1, err) } servers = append(servers, srv) - if len(servers) == totalSrv { - break - } + serversInCurrentBatch++ } return servers, nil } -// serverScanner implements bufio.SplitFunc for parsing the tracker list into ServerRecords tokens -// Example payload: -// 00000000 18 05 30 63 15 7c 00 02 00 00 10 54 68 65 20 4d |..0c.|.....The M| -// 00000010 6f 62 69 75 73 20 53 74 72 69 70 40 48 6f 6d 65 |obius Strip@Home| -// 00000020 20 6f 66 20 74 68 65 20 4d 6f 62 69 75 73 20 48 | of the Mobius H| -// 00000030 6f 74 6c 69 6e 65 20 73 65 72 76 65 72 20 61 6e |otline server an| -// 00000040 64 20 63 6c 69 65 6e 74 20 7c 20 54 52 54 50 48 |d client | TRTPH| -// 00000050 4f 54 4c 2e 63 6f 6d 3a 35 35 30 30 2d 4f 3a b2 |OTL.com:5500-O:.| -// 00000060 15 7c 00 00 00 00 08 53 65 6e 65 63 74 75 73 20 |.|.....Senectus | -func serverScanner(data []byte, _ bool) (advance int, token []byte, err error) { - // The name length field is the 11th byte of the server record. If we don't have that many bytes, - // return nil token so the Scanner reads more data and continues scanning. - if len(data) < 10 { - return 0, nil, nil +// readServerRecord reads a single ServerRecord from the reader +func readServerRecord(reader *bufio.Reader) (ServerRecord, error) { + var srv ServerRecord + + // Read fixed header: IP (4) + Port (2) + NumUsers (2) + TLSPort (2) = 10 bytes + header := make([]byte, 10) + if _, err := io.ReadFull(reader, header); err != nil { + return srv, fmt.Errorf("failed to read server header: %w", err) } - // A server entry has two variable length fields: the name and description. - // To get the token length, we first need the name length from the 10th byte - nameLen := int(data[10]) + copy(srv.IPAddr[:], header[0:4]) + copy(srv.Port[:], header[4:6]) + copy(srv.NumUsers[:], header[6:8]) + copy(srv.TLSPort[:], header[8:10]) - // The description length field is at the 12th + nameLen byte of the server record. - // If we don't have that many bytes, return nil token so the Scanner reads more data and continues scanning. - if len(data) < 11+nameLen { - return 0, nil, nil + // Read name size + nameSizeByte, err := reader.ReadByte() + if err != nil { + return srv, fmt.Errorf("failed to read name size: %w", err) + } + srv.NameSize = nameSizeByte + + // Read name + srv.Name = make([]byte, srv.NameSize) + if _, err := io.ReadFull(reader, srv.Name); err != nil { + return srv, fmt.Errorf("failed to read name: %w", err) } - // Next we need the description length from the 11+nameLen byte: - descLen := int(data[11+nameLen]) + // Read description size + descSizeByte, err := reader.ReadByte() + if err != nil { + return srv, fmt.Errorf("failed to read description size: %w", err) + } + srv.DescriptionSize = descSizeByte - if len(data) < 12+nameLen+descLen { - return 0, nil, nil + // Read description + srv.Description = make([]byte, srv.DescriptionSize) + if _, err := io.ReadFull(reader, srv.Description); err != nil { + return srv, fmt.Errorf("failed to read description: %w", err) } - return 12 + nameLen + descLen, data[0 : 12+nameLen+descLen], nil + return srv, nil } // Write implements io.Writer for ServerRecord func (s *ServerRecord) Write(b []byte) (n int, err error) { - if len(b) < 13 { + if len(b) < 12 { return 0, errors.New("too few bytes") } copy(s.IPAddr[:], b[0:4]) diff --git a/hotline/tracker_test.go b/hotline/tracker_test.go index 5457e47..242deb1 100644 --- a/hotline/tracker_test.go +++ b/hotline/tracker_test.go @@ -2,11 +2,11 @@ package hotline import ( "bytes" - "fmt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "io" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestTrackerRegistration_Payload(t *testing.T) { @@ -62,138 +62,6 @@ func TestTrackerRegistration_Payload(t *testing.T) { } } -func Test_serverScanner(t *testing.T) { - type args struct { - data []byte - atEOF bool - } - tests := []struct { - name string - args args - wantAdvance int - wantToken []byte - wantErr assert.ErrorAssertionFunc - }{ - { - name: "when a full server entry is provided", - args: args{ - data: []byte{ - 0x18, 0x05, 0x30, 0x63, // IP Addr - 0x15, 0x7c, // Port - 0x00, 0x02, // UserCount - 0x00, 0x00, // ?? - 0x03, // Name Len - 0x54, 0x68, 0x65, // Name - 0x03, // Desc Len - 0x54, 0x54, 0x54, // Description - }, - atEOF: false, - }, - wantAdvance: 18, - wantToken: []byte{ - 0x18, 0x05, 0x30, 0x63, // IP Addr - 0x15, 0x7c, // Port - 0x00, 0x02, // UserCount - 0x00, 0x00, // ?? - 0x03, // Name Len - 0x54, 0x68, 0x65, // Name - 0x03, // Desc Len - 0x54, 0x54, 0x54, // Description - }, - wantErr: assert.NoError, - }, - { - name: "when extra bytes are provided", - args: args{ - data: []byte{ - 0x18, 0x05, 0x30, 0x63, // IP Addr - 0x15, 0x7c, // Port - 0x00, 0x02, // UserCount - 0x00, 0x00, // ?? - 0x03, // Name Len - 0x54, 0x68, 0x65, // Name - 0x03, // Desc Len - 0x54, 0x54, 0x54, // Description - 0x54, 0x54, 0x54, 0x54, 0x54, 0x54, 0x54, 0x54, 0x54, - }, - atEOF: false, - }, - wantAdvance: 18, - wantToken: []byte{ - 0x18, 0x05, 0x30, 0x63, // IP Addr - 0x15, 0x7c, // Port - 0x00, 0x02, // UserCount - 0x00, 0x00, // ?? - 0x03, // Name Len - 0x54, 0x68, 0x65, // Name - 0x03, // Desc Len - 0x54, 0x54, 0x54, // Description - }, - wantErr: assert.NoError, - }, - { - name: "when insufficient bytes are provided", - args: args{ - data: []byte{ - 0, 0, - }, - atEOF: false, - }, - wantAdvance: 0, - wantToken: []byte(nil), - wantErr: assert.NoError, - }, - { - name: "when nameLen exceeds provided data", - args: args{ - data: []byte{ - 0x18, 0x05, 0x30, 0x63, // IP Addr - 0x15, 0x7c, // Port - 0x00, 0x02, // UserCount - 0x00, 0x00, // ?? - 0xff, // Name Len - 0x54, 0x68, 0x65, // Name - 0x03, // Desc Len - 0x54, 0x54, 0x54, // Description - }, - atEOF: false, - }, - wantAdvance: 0, - wantToken: []byte(nil), - wantErr: assert.NoError, - }, - { - name: "when description len exceeds provided data", - args: args{ - data: []byte{ - 0x18, 0x05, 0x30, 0x63, // IP Addr - 0x15, 0x7c, // Port - 0x00, 0x02, // UserCount - 0x00, 0x00, // ?? - 0x03, // Name Len - 0x54, 0x68, 0x65, // Name - 0xff, // Desc Len - 0x54, 0x54, 0x54, // Description - }, - atEOF: false, - }, - wantAdvance: 0, - wantToken: []byte(nil), - wantErr: assert.NoError, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotAdvance, gotToken, err := serverScanner(tt.args.data, tt.args.atEOF) - if !tt.wantErr(t, err, fmt.Sprintf("serverScanner(%v, %v)", tt.args.data, tt.args.atEOF)) { - return - } - assert.Equalf(t, tt.wantAdvance, gotAdvance, "serverScanner(%v, %v)", tt.args.data, tt.args.atEOF) - assert.Equalf(t, tt.wantToken, gotToken, "serverScanner(%v, %v)", tt.args.data, tt.args.atEOF) - }) - } -} - type mockConn struct { readBuffer *bytes.Buffer writeBuffer *bytes.Buffer @@ -231,12 +99,12 @@ func TestGetListing(t *testing.T) { 0x00, 0x01, // MsgType (1) 0x00, 0x14, // MsgDataSize (20) 0x00, 0x02, // SrvCount (2) - 0x00, 0x02, // SrvCountDup (2) + 0x00, 0x02, // BatchSize (2) // ServerRecord 1 192, 168, 1, 1, // IP address 0x1F, 0x90, // Port 8080 0x00, 0x10, // NumUsers 16 - 0x00, 0x00, // Unused + 0x00, 0x00, // TLSPort 0x04, // NameSize 'S', 'e', 'r', 'v', // Name 0x0B, // DescriptionSize @@ -245,7 +113,7 @@ func TestGetListing(t *testing.T) { 10, 0, 0, 1, // IP address 0x1F, 0x91, // Port 8081 0x00, 0x05, // NumUsers 5 - 0x00, 0x00, // Unused + 0x00, 0x00, // TLSPort 0x04, // NameSize 'S', 'e', 'r', 'v', // Name 0x0B, // DescriptionSize @@ -259,7 +127,7 @@ func TestGetListing(t *testing.T) { IPAddr: [4]byte{192, 168, 1, 1}, Port: [2]byte{0x1F, 0x90}, NumUsers: [2]byte{0x00, 0x10}, - Unused: [2]byte{0x00, 0x00}, + TLSPort: [2]byte{0x00, 0x00}, NameSize: 4, Name: []byte("Serv"), DescriptionSize: 11, @@ -269,7 +137,7 @@ func TestGetListing(t *testing.T) { IPAddr: [4]byte{10, 0, 0, 1}, Port: [2]byte{0x1F, 0x91}, NumUsers: [2]byte{0x00, 0x05}, - Unused: [2]byte{0x00, 0x00}, + TLSPort: [2]byte{0x00, 0x00}, NameSize: 4, Name: []byte("Serv"), DescriptionSize: 11, @@ -324,7 +192,7 @@ func TestGetListing(t *testing.T) { 0x00, 0x01, // MsgType (1) 0x00, 0x14, // MsgDataSize (20) 0x00, 0x01, // SrvCount (1) - 0x00, 0x01, // SrvCountDup (1) + 0x00, 0x01, // BatchSize (1) // incomplete ServerRecord to cause scanner error 192, 168, 1, 1, }), @@ -333,6 +201,239 @@ func TestGetListing(t *testing.T) { wantErr: true, wantResult: nil, }, + { + name: "Multiple batches with ServerInfoHeaders", + mockConn: &mockConn{ + readBuffer: bytes.NewBuffer([]byte{ + // TrackerHeader + 0x48, 0x54, 0x52, 0x4B, // Protocol "HTRK" + 0x00, 0x01, // Version 1 + // First ServerInfoHeader + 0x00, 0x01, // MsgType (1) + 0x00, 0x14, // MsgDataSize (20) + 0x00, 0x03, // SrvCount (3 total) + 0x00, 0x02, // BatchSize (2 in first batch) + // ServerRecord 1 + 192, 168, 1, 1, // IP address + 0x1F, 0x90, // Port 8080 + 0x00, 0x0A, // NumUsers 10 + 0x00, 0x00, // TLSPort + 0x07, // NameSize + 'S', 'e', 'r', 'v', 'e', 'r', '1', // Name + 0x0C, // DescriptionSize + 'F', 'i', 'r', 's', 't', ' ', 'b', 'a', 't', 'c', 'h', '1', // Description + // ServerRecord 2 + 192, 168, 1, 2, // IP address + 0x1F, 0x91, // Port 8081 + 0x00, 0x14, // NumUsers 20 + 0x00, 0x00, // TLSPort + 0x07, // NameSize + 'S', 'e', 'r', 'v', 'e', 'r', '2', // Name + 0x0C, // DescriptionSize + 'F', 'i', 'r', 's', 't', ' ', 'b', 'a', 't', 'c', 'h', '2', // Description + // Second ServerInfoHeader (next batch) + 0x00, 0x01, // MsgType (1) + 0x00, 0x0A, // MsgDataSize (10) + 0x00, 0x03, // SrvCount (3 total - same) + 0x00, 0x01, // BatchSize (1 in second batch) + // ServerRecord 3 + 192, 168, 1, 3, // IP address + 0x1F, 0x92, // Port 8082 + 0x00, 0x1E, // NumUsers 30 + 0x00, 0x00, // TLSPort + 0x07, // NameSize + 'S', 'e', 'r', 'v', 'e', 'r', '3', // Name + 0x0D, // DescriptionSize + 'S', 'e', 'c', 'o', 'n', 'd', ' ', 'b', 'a', 't', 'c', 'h', '1', // Description + }), + writeBuffer: &bytes.Buffer{}, + }, + wantErr: false, + wantResult: []ServerRecord{ + { + IPAddr: [4]byte{192, 168, 1, 1}, + Port: [2]byte{0x1F, 0x90}, + NumUsers: [2]byte{0x00, 0x0A}, + TLSPort: [2]byte{0x00, 0x00}, + NameSize: 7, + Name: []byte("Server1"), + DescriptionSize: 12, + Description: []byte("First batch1"), + }, + { + IPAddr: [4]byte{192, 168, 1, 2}, + Port: [2]byte{0x1F, 0x91}, + NumUsers: [2]byte{0x00, 0x14}, + TLSPort: [2]byte{0x00, 0x00}, + NameSize: 7, + Name: []byte("Server2"), + DescriptionSize: 12, + Description: []byte("First batch2"), + }, + { + IPAddr: [4]byte{192, 168, 1, 3}, + Port: [2]byte{0x1F, 0x92}, + NumUsers: [2]byte{0x00, 0x1E}, + TLSPort: [2]byte{0x00, 0x00}, + NameSize: 7, + Name: []byte("Server3"), + DescriptionSize: 13, + Description: []byte("Second batch1"), + }, + }, + }, + { + name: "Three batches", + mockConn: &mockConn{ + readBuffer: bytes.NewBuffer([]byte{ + // TrackerHeader + 0x48, 0x54, 0x52, 0x4B, // Protocol "HTRK" + 0x00, 0x01, // Version 1 + // First ServerInfoHeader + 0x00, 0x01, // MsgType (1) + 0x00, 0x0A, // MsgDataSize + 0x00, 0x04, // SrvCount (4 total) + 0x00, 0x02, // BatchSize (2 in first batch) + // ServerRecord 1 + 192, 168, 1, 1, // IP + 0x15, 0x7c, // Port 5500 + 0x00, 0x01, // NumUsers 1 + 0x00, 0x00, // TLSPort + 0x01, // NameSize + 'A', // Name + 0x01, // DescriptionSize + '1', // Description + // ServerRecord 2 + 192, 168, 1, 2, // IP + 0x15, 0x7c, // Port 5500 + 0x00, 0x02, // NumUsers 2 + 0x00, 0x00, // TLSPort + 0x01, // NameSize + 'B', // Name + 0x01, // DescriptionSize + '2', // Description + // Second ServerInfoHeader + 0x00, 0x01, // MsgType (1) + 0x00, 0x0A, // MsgDataSize + 0x00, 0x04, // SrvCount (4 total) + 0x00, 0x01, // BatchSize (1 in second batch) + // ServerRecord 3 + 192, 168, 1, 3, // IP + 0x15, 0x7c, // Port 5500 + 0x00, 0x03, // NumUsers 3 + 0x00, 0x00, // TLSPort + 0x01, // NameSize + 'C', // Name + 0x01, // DescriptionSize + '3', // Description + // Third ServerInfoHeader + 0x00, 0x01, // MsgType (1) + 0x00, 0x0A, // MsgDataSize + 0x00, 0x04, // SrvCount (4 total) + 0x00, 0x01, // BatchSize (1 in third batch) + // ServerRecord 4 + 192, 168, 1, 4, // IP + 0x15, 0x7c, // Port 5500 + 0x00, 0x04, // NumUsers 4 + 0x00, 0x00, // TLSPort + 0x01, // NameSize + 'D', // Name + 0x01, // DescriptionSize + '4', // Description + }), + writeBuffer: &bytes.Buffer{}, + }, + wantErr: false, + wantResult: []ServerRecord{ + { + IPAddr: [4]byte{192, 168, 1, 1}, + Port: [2]byte{0x15, 0x7c}, + NumUsers: [2]byte{0x00, 0x01}, + TLSPort: [2]byte{0x00, 0x00}, + NameSize: 1, + Name: []byte("A"), + DescriptionSize: 1, + Description: []byte("1"), + }, + { + IPAddr: [4]byte{192, 168, 1, 2}, + Port: [2]byte{0x15, 0x7c}, + NumUsers: [2]byte{0x00, 0x02}, + TLSPort: [2]byte{0x00, 0x00}, + NameSize: 1, + Name: []byte("B"), + DescriptionSize: 1, + Description: []byte("2"), + }, + { + IPAddr: [4]byte{192, 168, 1, 3}, + Port: [2]byte{0x15, 0x7c}, + NumUsers: [2]byte{0x00, 0x03}, + TLSPort: [2]byte{0x00, 0x00}, + NameSize: 1, + Name: []byte("C"), + DescriptionSize: 1, + Description: []byte("3"), + }, + { + IPAddr: [4]byte{192, 168, 1, 4}, + Port: [2]byte{0x15, 0x7c}, + NumUsers: [2]byte{0x00, 0x04}, + TLSPort: [2]byte{0x00, 0x00}, + NameSize: 1, + Name: []byte("D"), + DescriptionSize: 1, + Description: []byte("4"), + }, + }, + }, + { + name: "Error reading second ServerInfoHeader", + mockConn: &mockConn{ + readBuffer: bytes.NewBuffer([]byte{ + // TrackerHeader + 0x48, 0x54, 0x52, 0x4B, // Protocol "HTRK" + 0x00, 0x01, // Version 1 + // First ServerInfoHeader + 0x00, 0x01, // MsgType (1) + 0x00, 0x0A, // MsgDataSize + 0x00, 0x02, // SrvCount (2 total) + 0x00, 0x01, // BatchSize (1 in first batch) + // ServerRecord 1 + 192, 168, 1, 1, // IP + 0x15, 0x7c, // Port 5500 + 0x00, 0x01, // NumUsers 1 + 0x00, 0x00, // TLSPort + 0x01, // NameSize + 'A', // Name + 0x01, // DescriptionSize + '1', // Description + // Incomplete second ServerInfoHeader + 0x00, 0x01, // MsgType only + }), + writeBuffer: &bytes.Buffer{}, + }, + wantErr: true, + wantResult: nil, + }, + { + name: "Empty server list", + mockConn: &mockConn{ + readBuffer: bytes.NewBuffer([]byte{ + // TrackerHeader + 0x48, 0x54, 0x52, 0x4B, // Protocol "HTRK" + 0x00, 0x01, // Version 1 + // ServerInfoHeader with 0 servers + 0x00, 0x01, // MsgType (1) + 0x00, 0x00, // MsgDataSize (0) + 0x00, 0x00, // SrvCount (0) + 0x00, 0x00, // BatchSize (0) + }), + writeBuffer: &bytes.Buffer{}, + }, + wantErr: false, + wantResult: []ServerRecord{}, + }, } for _, tt := range tests { diff --git a/hotline/transaction.go b/hotline/transaction.go index fa1e96d..a03030d 100644 --- a/hotline/transaction.go +++ b/hotline/transaction.go @@ -275,7 +275,7 @@ func (t *Transaction) Size() []byte { return bs } -func (t *Transaction) GetField(id [2]byte) *Field { +func (t *Transaction) GetField(id FieldType) *Field { for _, field := range t.Fields { if id == field.Type { return &field diff --git a/hotline/transfer_test.go b/hotline/transfer_test.go index 4aa142b..9155ad7 100644 --- a/hotline/transfer_test.go +++ b/hotline/transfer_test.go @@ -120,14 +120,14 @@ func Test_receiveFile(t *testing.T) { conn: func() io.Reader { testFile := flattenedFileObject{ FlatFileHeader: FlatFileHeader{ - Format: [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP" + Format: FormatFILP, Version: [2]byte{0, 1}, ForkCount: [2]byte{0, 2}, }, FlatFileInformationForkHeader: FlatFileForkHeader{}, FlatFileInformationFork: NewFlatFileInformationFork("testfile.txt", [8]byte{}, "TEXT", "TEXT"), FlatFileDataForkHeader: FlatFileForkHeader{ - ForkType: [4]byte{0x4d, 0x41, 0x43, 0x52}, // DATA + ForkType: ForkTypeMACR, DataSize: [4]byte{0x00, 0x00, 0x00, 0x03}, }, } @@ -143,7 +143,7 @@ func Test_receiveFile(t *testing.T) { wantErr: assert.NoError, }, // { - // Name: "transfers fileWrapper when there is a resource fork", + // Name: "transfers File when there is a resource fork", // args: args{ // conn: func() io.Reader { // testFile := flattenedFileObject{ |