diff options
| author | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-03-15 14:54:44 -0700 |
|---|---|---|
| committer | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-03-15 14:54:44 -0700 |
| commit | f78ab940a8c09702be3e0ba310ac56e02fa76701 (patch) | |
| tree | 4b128f55860d1ce7ef9c87861179d97f5732b476 /hotline | |
| parent | 72c6ddc104c349a4798fddeaa8f77dbe139fe104 (diff) | |
Add configurable text encoding for file and folder names
Replace hardcoded Mac Roman encoding globals with a configurable Encoding
field in config.yaml. Servers that exclusively serve modern UTF-8 clients
can now set Encoding: utf8 to disable Mac Roman conversion. The default
remains "macintosh" for backward compatibility.
- Add Encoding field to Config struct (macintosh|utf8)
- Store TextDecoder/TextEncoder on Server, initialized from config
- Add TextDecoder()/TextEncoder() accessors on ClientConn
- Pass decoder/encoder explicitly to ReadPath and GetFileNameList
- Remove package-level txtEncoder/txtDecoder globals
- Add warning log when files are skipped due to encoding errors
- Log and return error replies in HandleGetFileNameList on failure
- Document the new config option in docs/text-encoding.md
Diffstat (limited to 'hotline')
| -rw-r--r-- | hotline/client_conn.go | 4 | ||||
| -rw-r--r-- | hotline/config.go | 1 | ||||
| -rw-r--r-- | hotline/file_path.go | 10 | ||||
| -rw-r--r-- | hotline/file_path_test.go | 54 | ||||
| -rw-r--r-- | hotline/files.go | 8 | ||||
| -rw-r--r-- | hotline/files_test.go | 49 | ||||
| -rw-r--r-- | hotline/server.go | 22 | ||||
| -rw-r--r-- | hotline/server_test.go | 54 |
8 files changed, 188 insertions, 14 deletions
diff --git a/hotline/client_conn.go b/hotline/client_conn.go index d8fd435..5428ea5 100644 --- a/hotline/client_conn.go +++ b/hotline/client_conn.go @@ -11,6 +11,7 @@ import ( "sync" "golang.org/x/crypto/bcrypt" + "golang.org/x/text/encoding" ) var clientConnSortFunc = func(a, b *ClientConn) int { @@ -44,6 +45,9 @@ type ClientConn struct { mu sync.RWMutex } +func (cc *ClientConn) TextDecoder() *encoding.Decoder { return cc.Server.TextDecoder } +func (cc *ClientConn) TextEncoder() *encoding.Encoder { return cc.Server.TextEncoder } + func (cc *ClientConn) FileRoot() string { if cc.Account.FileRoot != "" { return cc.Account.FileRoot diff --git a/hotline/config.go b/hotline/config.go index d918ecb..8b6adff 100644 --- a/hotline/config.go +++ b/hotline/config.go @@ -15,4 +15,5 @@ type Config struct { PreserveResourceForks bool `yaml:"PreserveResourceForks"` // Enable preservation of file info and resource forks in sidecar files IgnoreFiles []string `yaml:"IgnoreFiles"` // List of regular expression for filtering files from the file list EnableBonjour bool `yaml:"EnableBonjour"` // Enable service announcement on local network with Bonjour + Encoding string `yaml:"Encoding" validate:"omitempty,oneof=macintosh utf8"` // Text encoding for client communication } diff --git a/hotline/file_path.go b/hotline/file_path.go index c97faea..6bd538f 100644 --- a/hotline/file_path.go +++ b/hotline/file_path.go @@ -9,6 +9,8 @@ import ( "io" "path" "strings" + + "golang.org/x/text/encoding" ) // FilePathItem represents the file or directory portion of a delimited file path (e.g. foo and bar in "/foo/bar") @@ -102,7 +104,7 @@ func (fp *FilePath) Len() uint16 { return binary.BigEndian.Uint16(fp.ItemCount[:]) } -func ReadPath(fileRoot string, filePath, fileName []byte) (fullPath string, err error) { +func ReadPath(fileRoot string, filePath, fileName []byte, decoder *encoding.Decoder) (fullPath string, err error) { var fp FilePath if filePath != nil { if _, err = fp.Write(filePath); err != nil { @@ -115,14 +117,14 @@ func ReadPath(fileRoot string, filePath, fileName []byte) (fullPath string, err subPath = path.Join("/", subPath, string(pathItem.Name)) } - // Decode only client-provided path components from Mac Roman to UTF-8. + // Decode only client-provided path components using the configured text encoding. // The fileRoot is already a UTF-8 filesystem path and must not be decoded. - subPath, err = txtDecoder.String(subPath) + subPath, err = decoder.String(subPath) if err != nil { return "", fmt.Errorf("invalid filepath encoding: %w", err) } - decodedFileName, err := txtDecoder.String(string(fileName)) + decodedFileName, err := decoder.String(string(fileName)) if err != nil { return "", fmt.Errorf("invalid filename encoding: %w", err) } diff --git a/hotline/file_path_test.go b/hotline/file_path_test.go index c86a8d9..9fe82f5 100644 --- a/hotline/file_path_test.go +++ b/hotline/file_path_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + "golang.org/x/text/encoding" + "golang.org/x/text/encoding/charmap" ) func TestFilePath_Write(t *testing.T) { @@ -178,7 +180,7 @@ func Test_readPath(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ReadPath(tt.args.fileRoot, tt.args.filePath, tt.args.fileName) + got, err := ReadPath(tt.args.fileRoot, tt.args.filePath, tt.args.fileName, charmap.Macintosh.NewDecoder()) if (err != nil) != tt.wantErr { t.Errorf("ReadPath() error = %v, wantErr %v", err, tt.wantErr) return @@ -190,6 +192,56 @@ func Test_readPath(t *testing.T) { } } +func Test_readPath_NopDecoder(t *testing.T) { + type args struct { + fileRoot string + filePath []byte + fileName []byte + } + tests := []struct { + name string + args args + want string + }{ + { + name: "Mac Roman bytes pass through unchanged with Nop decoder", + args: args{ + fileRoot: "/files", + filePath: []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x06, + 0x63, 0x61, 0x66, 0x8e, 0x73, 0x21, // "caf\x8es!" where 0x8e is Mac Roman é + }, + fileName: []byte("foo"), + }, + // With Nop decoder, 0x8e is NOT converted to UTF-8 é — it passes through as the raw byte. + want: "/files/caf\x8es!/foo", + }, + { + name: "plain ASCII is unchanged with Nop decoder", + args: args{ + fileRoot: "/files", + filePath: nil, + fileName: []byte("hello.txt"), + }, + want: "/files/hello.txt", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ReadPath(tt.args.fileRoot, tt.args.filePath, tt.args.fileName, encoding.Nop.NewDecoder()) + if err != nil { + t.Errorf("ReadPath() error = %v", err) + return + } + if got != tt.want { + t.Errorf("ReadPath() got = %q, want %q", got, tt.want) + } + }) + } +} + func Test_fileItemScanner(t *testing.T) { type args struct { data []byte diff --git a/hotline/files.go b/hotline/files.go index 581b11c..a5b3441 100644 --- a/hotline/files.go +++ b/hotline/files.go @@ -6,10 +6,13 @@ import ( "fmt" "io" "io/fs" + "log/slog" "os" "path/filepath" "regexp" "strings" + + "golang.org/x/text/encoding" ) func FileTypeFromFilename(filename string) fileType { @@ -34,7 +37,7 @@ func fileTypeFromInfo(info fs.FileInfo) (ft fileType, err error) { const maxFileSize = 4294967296 -func GetFileNameList(path string, ignoreList []string) (fields []Field, err error) { +func GetFileNameList(path string, ignoreList []string, encoder *encoding.Encoder, logger *slog.Logger) (fields []Field, err error) { files, err := os.ReadDir(path) if err != nil { return fields, fmt.Errorf("error reading path: %s: %w", path, err) @@ -123,8 +126,9 @@ func GetFileNameList(path string, ignoreList []string) (fields []Field, err erro } strippedName := strings.ReplaceAll(file.Name(), ".incomplete", "") - strippedName, err = txtEncoder.String(strippedName) + strippedName, err = encoder.String(strippedName) if err != nil { + logger.Warn("skipping file with unencodable name", "name", file.Name(), "err", err) continue } diff --git a/hotline/files_test.go b/hotline/files_test.go index 3070313..3d9846f 100644 --- a/hotline/files_test.go +++ b/hotline/files_test.go @@ -3,11 +3,14 @@ package hotline import ( "bytes" "encoding/binary" + "log/slog" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" + "golang.org/x/text/encoding" + "golang.org/x/text/encoding/charmap" ) func TestEncodeFilePath(t *testing.T) { @@ -170,3 +173,49 @@ func TestCalcItemCount(t *testing.T) { }) } } + +func TestGetFileNameList_Encoding(t *testing.T) { + // Create a temp dir with a file whose name contains a non-ASCII UTF-8 character. + tempDir, err := os.MkdirTemp("", "test-encoding") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + // "café.txt" — the é is UTF-8 on disk + if err := os.WriteFile(filepath.Join(tempDir, "café.txt"), []byte("hello"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // FileNameWithInfoHeader is 20 bytes: Type[4] + Creator[4] + FileSize[4] + RSVD[4] + NameScript[2] + NameSize[2] + const headerLen = 20 + + extractName := func(fields []Field) string { + for _, f := range fields { + if f.Type == FieldFileNameWithInfo { + nameSize := binary.BigEndian.Uint16(f.Data[headerLen-2 : headerLen]) + return string(f.Data[headerLen : headerLen+nameSize]) + } + } + return "" + } + + t.Run("macintosh encoder converts UTF-8 to Mac Roman", func(t *testing.T) { + fields, err := GetFileNameList(tempDir, nil, charmap.Macintosh.NewEncoder(), slog.Default()) + assert.NoError(t, err) + assert.Len(t, fields, 1) + + name := extractName(fields) + // Mac Roman é is 0x8e, so "café.txt" becomes "caf\x8e.txt" + assert.Equal(t, "caf\x8e.txt", name) + }) + + t.Run("nop encoder passes UTF-8 through unchanged", func(t *testing.T) { + fields, err := GetFileNameList(tempDir, nil, encoding.Nop.NewEncoder(), slog.Default()) + assert.NoError(t, err) + assert.Len(t, fields, 1) + + name := extractName(fields) + assert.Equal(t, "café.txt", name) + }) +} diff --git a/hotline/server.go b/hotline/server.go index 545e84c..dcb90e7 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -18,6 +18,7 @@ import ( "time" "github.com/redis/go-redis/v9" + "golang.org/x/text/encoding" "golang.org/x/text/encoding/charmap" "golang.org/x/time/rate" ) @@ -30,12 +31,6 @@ type requestCtx struct { remoteAddr string } -// Converts bytes from Mac Roman encoding to UTF-8 -var txtDecoder = charmap.Macintosh.NewDecoder() - -// Converts bytes from UTF-8 to Mac Roman encoding -var txtEncoder = charmap.Macintosh.NewEncoder() - type Server struct { NetInterface string Port int @@ -73,6 +68,9 @@ type Server struct { // TrackerRegistrar handles tracker registration (injectable for testing) TrackerRegistrar TrackerRegistrar + TextDecoder *encoding.Decoder + TextEncoder *encoding.Encoder + TLSConfig *tls.Config TLSPort int } @@ -140,6 +138,16 @@ func NewServer(options ...Option) (*Server, error) { opt(&server) } + // Initialize text encoding based on config. + switch server.Config.Encoding { + case "utf8": + server.TextDecoder = encoding.Nop.NewDecoder() + server.TextEncoder = encoding.Nop.NewEncoder() + default: + server.TextDecoder = charmap.Macintosh.NewDecoder() + server.TextEncoder = charmap.Macintosh.NewEncoder() + } + // generate a new random passID for tracker registration _, err := rand.Read(server.TrackerPassID[:]) if err != nil { @@ -690,7 +698,7 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro "Name", string(fileTransfer.ClientConn.UserName), ) - fullPath, err := ReadPath(fileTransfer.FileRoot, fileTransfer.FilePath, fileTransfer.FileName) + fullPath, err := ReadPath(fileTransfer.FileRoot, fileTransfer.FilePath, fileTransfer.FileName, s.TextDecoder) if err != nil { return err } diff --git a/hotline/server_test.go b/hotline/server_test.go index db70480..abc2f9a 100644 --- a/hotline/server_test.go +++ b/hotline/server_test.go @@ -13,6 +13,8 @@ import ( "time" "github.com/stretchr/testify/assert" + "golang.org/x/text/encoding" + "golang.org/x/text/encoding/charmap" ) type mockReadWriter struct { @@ -177,6 +179,8 @@ func TestServer_handleFileTransfer(t *testing.T) { Logger: tt.fields.Logger, Stats: tt.fields.Stats, FS: tt.fields.FS, + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), } tt.wantErr(t, s.handleFileTransfer(tt.args.ctx, tt.args.rwc), fmt.Sprintf("handleFileTransfer(%v, %v)", tt.args.ctx, tt.args.rwc)) @@ -186,6 +190,56 @@ func TestServer_handleFileTransfer(t *testing.T) { } } +func TestNewServer_Encoding(t *testing.T) { + tests := []struct { + name string + encoding string + wantDecoder *encoding.Decoder + wantEncoder *encoding.Encoder + }{ + { + name: "default empty string uses Mac Roman", + encoding: "", + wantDecoder: charmap.Macintosh.NewDecoder(), + wantEncoder: charmap.Macintosh.NewEncoder(), + }, + { + name: "macintosh uses Mac Roman", + encoding: "macintosh", + wantDecoder: charmap.Macintosh.NewDecoder(), + wantEncoder: charmap.Macintosh.NewEncoder(), + }, + { + name: "utf8 uses Nop (pass-through)", + encoding: "utf8", + wantDecoder: encoding.Nop.NewDecoder(), + wantEncoder: encoding.Nop.NewEncoder(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv, err := NewServer(WithConfig(Config{Encoding: tt.encoding})) + assert.NoError(t, err) + + // Verify encoder/decoder behavior by round-tripping a Mac Roman byte. + // 0x8e is Mac Roman for "é". + input := string([]byte{0x8e}) + + gotDec, err := srv.TextDecoder.String(input) + assert.NoError(t, err) + wantDec, err := tt.wantDecoder.String(input) + assert.NoError(t, err) + assert.Equal(t, wantDec, gotDec) + + gotEnc, err := srv.TextEncoder.String("é") + assert.NoError(t, err) + wantEnc, err := tt.wantEncoder.String("é") + assert.NoError(t, err) + assert.Equal(t, wantEnc, gotEnc) + }) + } +} + func TestParseTrackerPassword(t *testing.T) { tests := []struct { name string |