Repository: syncthing/syncthing (language: go)

Issue:
panic: string field contains invalid UTF-8 [recovered]
### What happened?

Hi,

Syncthing crashes with "panic: string field contains invalid UTF-8 [recovered]" since a few days. I'm running Syncthing on Debian 12 and Linux Mint 22 64 bit PCs and one Rapspberry Pi OS (32 bit), that is used for encrypted storing and broadcasting the synced directories.The crash happens on all machines but the Pi.

The setup worked for about 3 years without problem. I used ISO-8859-15 for filename encoding before the crash happened and converted the filenames to UTF-8 now in case that it could cause the problem. But the crash still happened.

I tried `syncthing -reset-database` and `syncthing --verbose` and enabling debugging in the config.xml, but it didn't help or didn't give me hints how to resolv the issue by myselv.

By changing the contents of a synced directory it seems that Syncthing crashes when scanning a subdirectory name that contains an "ü".

Would be great, if it can be fixed or if you could give a hint what changes I should do locally (eg. configuration).

Thank you,
 Hauke

### Syncthing version

v1.29.1

### Platform & operating system

Debian 12 AMD64, Linux Mint 22 AMD64

### Browser version

_No response_

### Relevant log output

```shell
[EEZTP] 2025/01/10 15:03:54 WARNING: Aborted scan due to an unexpected error: context canceled
panic: string field contains invalid UTF-8 [recovered]
[monitor] 2025/01/10 15:03:54 WARNING: Panic detected, writing to "/home/test/.local/state/syncthing/panic-20250110-150354.log"
[monitor] 2025/01/10 15:03:54 WARNING: Please check for existing issues with similar panic message at https://github.com/syncthing/syncthing/issues/
[monitor] 2025/01/10 15:03:54 WARNING: If no issue with similar panic message exists, please create a new issue with the panic log attached
[monitor] 2025/01/10 15:03:54 INFO: Syncthing exited: exit status 2
[monitor] 2025/01/10 15:03:55 WARNING: 4 restarts in 7.119028171s; not retrying further
```



Discussion / hints:
Can you paste the full first "paragraph" of the panic trace please?
I anonymised the output a bit, but it shouldn't change the tenor of it:

[start] 2025/01/10 17:30:05 INFO: syncthing v1.29.1 "Gold Grasshopper" (go1.23.4 linux-amd64) debian@github.syncthing.net 2025-01-09 20:47:10 UTC [noupgrade]
[EEZTP] 2025/01/10 17:30:05 INFO: My ID: EEZTPIA-...
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Starting up (/home/test/.config/syncthing)
[EEZTP] 2025/01/10 17:30:05 INFO: Hashing performance is 1159.06 MB/s
[EEZTP] 2025/01/10 17:30:05 INFO: Overall send rate is unlimited, receive rate is unlimited
[EEZTP] 2025/01/10 17:30:05 INFO: Using discovery mechanism: IPv4 local broadcast discovery on port 21027
[EEZTP] 2025/01/10 17:30:05 INFO: Using discovery mechanism: IPv6 local multicast discovery on address [ff12::8384]:21027
[EEZTP] 2025/01/10 17:30:05 INFO: Ready to synchronize "matrix23-sync" (cpaem-...) (sendreceive)
[EEZTP] 2025/01/10 17:30:05 INFO: TCP listener ([::]:2233) starting
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Listen address tcp://0.0.0.0:2233 resolution has changed: lan addresses: [tcp://0.0.0.0:2233 tcp://192.168.178.177:2233] wan addresses: [tcp://0.0.0.0:0 tcp://0.0.0.0:2233]
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "cpaem-..." is now scan-waiting
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "cpaem-..." is now scanning
[EEZTP] 2025/01/10 17:30:05 INFO: Ready to synchronize "Weisheit-Video" (epbpc-...) (sendreceive)
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "epbpc-..." is now scan-waiting
[EEZTP] 2025/01/10 17:30:05 INFO: Ready to synchronize "keepass" (gnkcd-...) (sendreceive)
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "epbpc-..." is now scanning
[EEZTP] 2025/01/10 17:30:05 INFO: GUI and API listening on 127.0.0.1:8384
[EEZTP] 2025/01/10 17:30:05 INFO: Access the GUI via the following URL: hclient6://localhost:8384/
[EEZTP] 2025/01/10 17:30:05 INFO: My name is "tux09"
[EEZTP] 2025/01/10 17:30:05 INFO: Device YU3UVTG-... is "client2" at [dynamic]
[EEZTP] 2025/01/10 17:30:05 INFO: Device DI3HEJ6-... is "client5" at [tcp://client5]
[EEZTP] 2025/01/10 17:30:05 INFO: Device LFDAIR5-... is "client3" at [dynamic]
[EEZTP] 2025/01/10 17:30:05 INFO: Device MZ5UBGZ-... is "client4" at [dynamic]
[EEZTP] 2025/01/10 17:30:05 INFO: Device PODUW3J-... is "client1" at [dynamic]
[EEZTP] 2025/01/10 17:30:05 INFO: Device S7P7Z4V-... is "client6" at [dynamic]
[EEZTP] 2025/01/10 17:30:05 INFO: Device U5F76TX-... is "pi" at [tcp4://192.168.178.5:2233]
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Startup complete
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "gnkcd-..." is now scan-waiting
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "gnkcd-..." is now scanning
[EEZTP] 2025/01/10 17:30:05 INFO: Ready to synchronize "Austausch" (qtiws-...) (sendreceive)
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "qtiws-..." is now scan-waiting
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "qtiws-..." is now scanning
[EEZTP] 2025/01/10 17:30:05 INFO: Ready to synchronize "camset-Konfiguration" (ytfk2-...) (sendreceive)
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "ytfk2-..." is now scan-waiting
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "ytfk2-..." is now scanning
[EEZTP] 2025/01/10 17:30:05 INFO: Completed initial scan of sendreceive folder "Sync1" (qtiws-...)
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "qtiws-..." is now idle
[EEZTP] 2025/01/10 17:30:05 INFO: Completed initial scan of sendreceive folder "Sync2" (gnkcd-...)
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "gnkcd-..." is now idle
[EEZTP] 2025/01/10 17:30:05 INFO: Completed initial scan of sendreceive folder "Sync3" (ytfk2-...)
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "ytfk2-..." is now idle
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Discovered device U5F76TX-... at [tcp4://192.168.178.5:2233]
[EEZTP] 2025/01/10 17:30:05 INFO: Completed initial scan of sendreceive folder "Sync4" (epbpc-...)
[EEZTP] 2025/01/10 17:30:05 VERBOSE: Folder "epbpc-..." is now idle
panic: string field contains invalid UTF-8 [recovered]
[monitor] 2025/01/10 17:30:05 WARNING: Panic detected, writing to "/home/test/.local/state/syncthing/panic-20250110-173005.log"
[monitor] 2025/01/10 17:30:05 WARNING: Please check for existing issues with similar panic message at hclient6s://github.com/syncthing/syncthing/issues/
[monitor] 2025/01/10 17:30:05 WARNING: If no issue with similar panic message exists, please create a new issue with the panic log attached
[monitor] 2025/01/10 17:30:05 INFO: Syncthing exited: exit status 2
In case it could be helpful:

[panic-20250110-173005.log](https://github.com/user-attachments/files/18379939/panic-20250110-173005.log)

Yes that's that one, thank you.
Could you enable `scanner` debugging on the log tab and run a scan, or set it from the outside before starting Syncthing with `export STTRACE=scanner` and post the last 20 or so lines before the panic? I'm trying to figure out how a non-UTF8 filename gets to the point where it apparently is...

Fix patch:
diff --git a/internal/gen/bep/bep.pb.go b/internal/gen/bep/bep.pb.go
index 7671339a46d..686fc6c0417 100644
--- a/internal/gen/bep/bep.pb.go
+++ b/internal/gen/bep/bep.pb.go
@@ -877,7 +877,7 @@ type FileInfo struct {
 	Version       *Vector       `protobuf:"bytes,9,opt,name=version,proto3" json:"version,omitempty"`
 	Sequence      int64         `protobuf:"varint,10,opt,name=sequence,proto3" json:"sequence,omitempty"`
 	Blocks        []*BlockInfo  `protobuf:"bytes,16,rep,name=blocks,proto3" json:"blocks,omitempty"`
-	SymlinkTarget string        `protobuf:"bytes,17,opt,name=symlink_target,json=symlinkTarget,proto3" json:"symlink_target,omitempty"`
+	SymlinkTarget []byte        `protobuf:"bytes,17,opt,name=symlink_target,json=symlinkTarget,proto3" json:"symlink_target,omitempty"`
 	BlocksHash    []byte        `protobuf:"bytes,18,opt,name=blocks_hash,json=blocksHash,proto3" json:"blocks_hash,omitempty"`
 	Encrypted     []byte        `protobuf:"bytes,19,opt,name=encrypted,proto3" json:"encrypted,omitempty"`
 	Type          FileInfoType  `protobuf:"varint,2,opt,name=type,proto3,enum=bep.FileInfoType" json:"type,omitempty"`
@@ -983,11 +983,11 @@ func (x *FileInfo) GetBlocks() []*BlockInfo {
 	return nil
 }
 
-func (x *FileInfo) GetSymlinkTarget() string {
+func (x *FileInfo) GetSymlinkTarget() []byte {
 	if x != nil {
 		return x.SymlinkTarget
 	}
-	return ""
+	return nil
 }
 
 func (x *FileInfo) GetBlocksHash() []byte {
@@ -2047,7 +2047,7 @@ var file_bep_bep_proto_rawDesc = []byte{
 	0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x62, 0x65, 0x70, 0x2e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e,
 	0x66, 0x6f, 0x52, 0x06, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x79,
 	0x6d, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x11, 0x20, 0x01,
-	0x28, 0x09, 0x52, 0x0d, 0x73, 0x79, 0x6d, 0x6c, 0x69, 0x6e, 0x6b, 0x54, 0x61, 0x72, 0x67, 0x65,
+	0x28, 0x0c, 0x52, 0x0d, 0x73, 0x79, 0x6d, 0x6c, 0x69, 0x6e, 0x6b, 0x54, 0x61, 0x72, 0x67, 0x65,
 	0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x5f, 0x68, 0x61, 0x73, 0x68,
 	0x18, 0x12, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x48, 0x61,
 	0x73, 0x68, 0x12, 0x1c, 0x0a, 0x09, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x18,
diff --git a/internal/gen/dbproto/structs.pb.go b/internal/gen/dbproto/structs.pb.go
index 3a803347d01..48f588b98e5 100644
--- a/internal/gen/dbproto/structs.pb.go
+++ b/internal/gen/dbproto/structs.pb.go
@@ -34,7 +34,7 @@ type FileInfoTruncated struct {
 	ModifiedBy    uint64            `protobuf:"varint,12,opt,name=modified_by,json=modifiedBy,proto3" json:"modified_by,omitempty"`
 	Version       *bep.Vector       `protobuf:"bytes,9,opt,name=version,proto3" json:"version,omitempty"`
 	Sequence      int64             `protobuf:"varint,10,opt,name=sequence,proto3" json:"sequence,omitempty"`
-	SymlinkTarget string            `protobuf:"bytes,17,opt,name=symlink_target,json=symlinkTarget,proto3" json:"symlink_target,omitempty"`
+	SymlinkTarget []byte            `protobuf:"bytes,17,opt,name=symlink_target,json=symlinkTarget,proto3" json:"symlink_target,omitempty"`
 	BlocksHash    []byte            `protobuf:"bytes,18,opt,name=blocks_hash,json=blocksHash,proto3" json:"blocks_hash,omitempty"`
 	Encrypted     []byte            `protobuf:"bytes,19,opt,name=encrypted,proto3" json:"encrypted,omitempty"`
 	Type          bep.FileInfoType  `protobuf:"varint,2,opt,name=type,proto3,enum=bep.FileInfoType" json:"type,omitempty"`
@@ -133,11 +133,11 @@ func (x *FileInfoTruncated) GetSequence() int64 {
 	return 0
 }
 
-func (x *FileInfoTruncated) GetSymlinkTarget() string {
+func (x *FileInfoTruncated) GetSymlinkTarget() []byte {
 	if x != nil {
 		return x.SymlinkTarget
 	}
-	return ""
+	return nil
 }
 
 func (x *FileInfoTruncated) GetBlocksHash() []byte {
@@ -760,7 +760,7 @@ var file_dbproto_structs_proto_rawDesc = []byte{
 	0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63,
 	0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63,
 	0x65, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x79, 0x6d, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x74, 0x61, 0x72,
-	0x67, 0x65, 0x74, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x79, 0x6d, 0x6c, 0x69,
+	0x67, 0x65, 0x74, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0d, 0x73, 0x79, 0x6d, 0x6c, 0x69,
 	0x6e, 0x6b, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x62, 0x6c, 0x6f, 0x63,
 	0x6b, 0x73, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x12, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x62,
 	0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x48, 0x61, 0x73, 0x68, 0x12, 0x1c, 0x0a, 0x09, 0x65, 0x6e, 0x63,
diff --git a/lib/model/folder_sendrecv.go b/lib/model/folder_sendrecv.go
index ceb74d130d2..73e3ef184d6 100644
--- a/lib/model/folder_sendrecv.go
+++ b/lib/model/folder_sendrecv.go
@@ -743,7 +743,7 @@ func (f *sendReceiveFolder) handleSymlink(file protocol.FileInfo, snap *db.Snaps
 		l.Debugf("need symlink\n\t%v\n\t%v", file, curFile)
 	}
 
-	if file.SymlinkTarget == "" {
+	if len(file.SymlinkTarget) == 0 {
 		// Index entry from a Syncthing predating the support for including
 		// the link target in the index entry. We log this as an error.
 		f.newPullError(file.Name, errIncompatibleSymlink)
@@ -758,7 +758,7 @@ func (f *sendReceiveFolder) handleSymlink(file protocol.FileInfo, snap *db.Snaps
 	// We declare a function that acts on only the path name, so
 	// we can pass it to InWritableDir.
 	createLink := func(path string) error {
-		if err := f.mtimefs.CreateSymlink(file.SymlinkTarget, path); err != nil {
+		if err := f.mtimefs.CreateSymlink(string(file.SymlinkTarget), path); err != nil {
 			return err
 		}
 		return f.setPlatformData(&file, path)
diff --git a/lib/protocol/bep_fileinfo.go b/lib/protocol/bep_fileinfo.go
index d8ed11b5ede..fa5f78d5d1b 100644
--- a/lib/protocol/bep_fileinfo.go
+++ b/lib/protocol/bep_fileinfo.go
@@ -66,7 +66,7 @@ type FileInfo struct {
 	Version       Vector
 	Sequence      int64
 	Blocks        []BlockInfo
-	SymlinkTarget string
+	SymlinkTarget []byte
 	BlocksHash    []byte
 	Encrypted     []byte
 	Platform      PlatformData
@@ -187,7 +187,7 @@ type FileInfoWithoutBlocks interface {
 	GetVersion() *bep.Vector
 	GetSequence() int64
 	// GetBlocks() []*bep.BlockInfo // not included
-	GetSymlinkTarget() string
+	GetSymlinkTarget() []byte
 	GetBlocksHash() []byte
 	GetEncrypted() []byte
 	GetType() FileInfoType
@@ -469,7 +469,7 @@ func (f FileInfo) isEquivalent(other FileInfo, comp FileInfoComparison) bool {
 	case FileInfoTypeFile:
 		return f.Size == other.Size && ModTimeEqual(f.ModTime(), other.ModTime(), comp.ModTimeWindow) && (comp.IgnoreBlocks || f.BlocksEqual(other))
 	case FileInfoTypeSymlink:
-		return f.SymlinkTarget == other.SymlinkTarget
+		return bytes.Equal(f.SymlinkTarget, other.SymlinkTarget)
 	case FileInfoTypeDirectory:
 		return true
 	}
diff --git a/lib/scanner/walk.go b/lib/scanner/walk.go
index d9d6f6e79cb..e76ce9651fc 100644
--- a/lib/scanner/walk.go
+++ b/lib/scanner/walk.go
@@ -740,7 +740,7 @@ func CreateFileInfo(fi fs.FileInfo, name string, filesystem fs.Filesystem, scanO
 		if err != nil {
 			return protocol.FileInfo{}, err
 		}
-		f.SymlinkTarget = target
+		f.SymlinkTarget = []byte(target)
 		f.NoPermissions = true // Symlinks don't have permissions of their own
 		return f, nil
 	}
diff --git a/proto/bep/bep.proto b/proto/bep/bep.proto
index c1dac02be9d..4cac5059700 100644
--- a/proto/bep/bep.proto
+++ b/proto/bep/bep.proto
@@ -101,7 +101,7 @@ message FileInfo {
   Vector version = 9;
   int64 sequence = 10;
   repeated BlockInfo blocks = 16;
-  string symlink_target = 17;
+  bytes symlink_target = 17;
   bytes blocks_hash = 18;
   bytes encrypted = 19;
   FileInfoType type = 2;
diff --git a/proto/dbproto/structs.proto b/proto/dbproto/structs.proto
index bc49b407903..f8ff2eaa416 100644
--- a/proto/dbproto/structs.proto
+++ b/proto/dbproto/structs.proto
@@ -15,7 +15,7 @@ message FileInfoTruncated {
   bep.Vector version = 9;
   int64 sequence = 10;
   reserved 16; // blocks
-  string symlink_target = 17;
+  bytes symlink_target = 17;
   bytes blocks_hash = 18;
   bytes encrypted = 19;
   bep.FileInfoType type = 2;


Test patch:
diff --git a/lib/model/fakeconns_test.go b/lib/model/fakeconns_test.go
index c51a87af4dc..0d929bb8200 100644
--- a/lib/model/fakeconns_test.go
+++ b/lib/model/fakeconns_test.go
@@ -97,7 +97,7 @@ func (f *fakeConnection) addFileLocked(name string, flags uint32, ftype protocol
 		file.Name = name
 		file.Type = ftype
 		file.Version = version
-		file.SymlinkTarget = string(data)
+		file.SymlinkTarget = data
 		file.NoPermissions = true
 	}
 	f.files = append(f.files, file)
diff --git a/lib/model/folder_sendrecv_test.go b/lib/model/folder_sendrecv_test.go
index c8e528208d0..69b35c0183b 100644
--- a/lib/model/folder_sendrecv_test.go
+++ b/lib/model/folder_sendrecv_test.go
@@ -891,7 +891,7 @@ func TestCopyOwner(t *testing.T) {
 		Name:          "foo/bar/sym",
 		Type:          protocol.FileInfoTypeSymlink,
 		Permissions:   0o644,
-		SymlinkTarget: "over the rainbow",
+		SymlinkTarget: []byte("over the rainbow"),
 	}
 
 	f.handleSymlink(symlink, snap, dbUpdateChan, scanChan)
@@ -958,7 +958,7 @@ func TestSRConflictReplaceFileByLink(t *testing.T) {
 
 	// Simulate remote creating a symlink with the same name
 	file.Type = protocol.FileInfoTypeSymlink
-	file.SymlinkTarget = "bar"
+	file.SymlinkTarget = []byte("bar")
 	rem := device1.Short()
 	file.Version = protocol.Vector{}.Update(rem)
 	file.ModifiedBy = rem
diff --git a/lib/protocol/bep_fileinfo_test.go b/lib/protocol/bep_fileinfo_test.go
index f3be6ba00dc..ec241db68e2 100644
--- a/lib/protocol/bep_fileinfo_test.go
+++ b/lib/protocol/bep_fileinfo_test.go
@@ -185,15 +185,15 @@ func TestIsEquivalent(t *testing.T) {
 
 		// The symlink target is checked for symlinks
 		{
-			a:  FileInfo{Type: FileInfoTypeSymlink, SymlinkTarget: "a"},
-			b:  FileInfo{Type: FileInfoTypeSymlink, SymlinkTarget: "b"},
+			a:  FileInfo{Type: FileInfoTypeSymlink, SymlinkTarget: []byte("a")},
+			b:  FileInfo{Type: FileInfoTypeSymlink, SymlinkTarget: []byte("b")},
 			eq: false,
 		},
 
 		// ... but not for non-symlinks
 		{
-			a:  FileInfo{Type: FileInfoTypeFile, SymlinkTarget: "a"},
-			b:  FileInfo{Type: FileInfoTypeFile, SymlinkTarget: "b"},
+			a:  FileInfo{Type: FileInfoTypeFile, SymlinkTarget: []byte("a")},
+			b:  FileInfo{Type: FileInfoTypeFile, SymlinkTarget: []byte("b")},
 			eq: true,
 		},
 	}
diff --git a/lib/scanner/walk_test.go b/lib/scanner/walk_test.go
index c483484a01d..5c6254d2956 100644
--- a/lib/scanner/walk_test.go
+++ b/lib/scanner/walk_test.go
@@ -367,7 +367,7 @@ func TestWalkSymlinkUnix(t *testing.T) {
 		if len(files[0].Blocks) != 0 {
 			t.Errorf("expected zero blocks for symlink, not %d", len(files[0].Blocks))
 		}
-		if files[0].SymlinkTarget != "../testdata" {
+		if string(files[0].SymlinkTarget) != "../testdata" {
 			t.Errorf("expected symlink to have target destination, not %q", files[0].SymlinkTarget)
 		}
 	}