aboutsummaryrefslogtreecommitdiff
path: root/hotline
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2026-03-15 14:54:44 -0700
committerJeff Halter <868228+jhalter@users.noreply.github.com>2026-03-15 14:54:44 -0700
commitf78ab940a8c09702be3e0ba310ac56e02fa76701 (patch)
tree4b128f55860d1ce7ef9c87861179d97f5732b476 /hotline
parent72c6ddc104c349a4798fddeaa8f77dbe139fe104 (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.go4
-rw-r--r--hotline/config.go1
-rw-r--r--hotline/file_path.go10
-rw-r--r--hotline/file_path_test.go54
-rw-r--r--hotline/files.go8
-rw-r--r--hotline/files_test.go49
-rw-r--r--hotline/server.go22
-rw-r--r--hotline/server_test.go54
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