From 204aa1735bb2e1966fae0897d2f0d3280ba04e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=98=E4=B8=96=E5=87=A1=E9=97=B4?= <98646630+chenshifanjian@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:30:53 +0800 Subject: [PATCH] clipboard: decode file uris and stop failing large file copies (#3598) EntryToFile and the preview helpers stripped file:// without percent-decoding, so history entries whose paths contain non-ASCII characters stat'ed a literal %E5... path and copyEntry failed with "file not found". CopyFile additionally hard-failed any file larger than MaxEntrySize (5MB default) right after stat, so even decoded entries for bigger files could never be restored to the clipboard. Decode the path in EntryToFile, uriListPreview and tryReadImageFromURI. In CopyFile only read file content when it fits the size cap; oversized files still get offered as uri-list / gnome-copied-files / plain path, and the stored entry keeps the file URI instead of an empty body. Keep encode and decode symmetric: every URI this service stores or offers now goes through encodeFileURI (percent-encoded) and every read path decodes exactly once through pathFromFileURI, so a literal %20 in a file name survives the round trip. Regression tests cover space / literal %20 / # / CJK / oversized names across EntryToFile, uriListPreview, the image probe and handleCopyEntry. --- core/internal/server/clipboard/manager.go | 61 ++++++++--- .../internal/server/clipboard/manager_test.go | 103 ++++++++++++++++++ 2 files changed, 149 insertions(+), 15 deletions(-) diff --git a/core/internal/server/clipboard/manager.go b/core/internal/server/clipboard/manager.go index 960974394..831cfc907 100644 --- a/core/internal/server/clipboard/manager.go +++ b/core/internal/server/clipboard/manager.go @@ -10,6 +10,7 @@ import ( _ "image/jpeg" _ "image/png" "io" + "net/url" "os" "os/exec" "path/filepath" @@ -825,6 +826,27 @@ func (m *Manager) imagePreview(data []byte, format string) string { return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height) } +// URI convention: what we store and what we offer on the clipboard are always +// percent-encoded file:// URIs; every read path decodes exactly once through +// pathFromFileURI, so encode and decode stay a true round-trip. +func encodeFileURI(path string) string { + return (&url.URL{Scheme: "file", Path: path}).String() +} + +// pathFromFileURI strips the file:// prefix and decodes once. A raw "%" that +// was never encoded (invalid escape) falls back to the raw remainder rather +// than mangling a real path. +func pathFromFileURI(uri string) string { + path, ok := strings.CutPrefix(uri, "file://") + if !ok { + return "" + } + if decoded, err := url.PathUnescape(path); err == nil { + return decoded + } + return path +} + func (m *Manager) uriListPreview(data []byte) (string, bool) { text := strings.TrimSpace(string(data)) uris := strings.Split(text, "\r\n") @@ -837,7 +859,7 @@ func (m *Manager) uriListPreview(data []byte) (string, bool) { } if len(uris) == 1 && strings.HasPrefix(uris[0], "file://") { - filePath := strings.TrimPrefix(uris[0], "file://") + filePath := pathFromFileURI(uris[0]) info, err := os.Stat(filePath) if err != nil || info.IsDir() { return m.textPreview(data), false @@ -868,7 +890,7 @@ func (m *Manager) tryReadImageFromURI(data []byte) ([]byte, string, bool) { return nil, "", false } - filePath := strings.TrimPrefix(uris[0], "file://") + filePath := pathFromFileURI(uris[0]) info, err := os.Stat(filePath) if err != nil || info.IsDir() { return nil, "", false @@ -2141,22 +2163,25 @@ func (m *Manager) CopyFile(filePath string) error { } cfg := m.getConfig() - if fileInfo.Size() > cfg.MaxEntrySize { - return fmt.Errorf("file too large: %d > %d", fileInfo.Size(), cfg.MaxEntrySize) - } - - fileData, err := os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("read file: %w", err) + // Files over MaxEntrySize are no longer read into memory (this used to + // fail the copy outright): the uri-list / plain path offers below let the + // paste target read the file itself, and the image probe size-guards + // internally. + var fileData []byte + if fileInfo.Size() <= cfg.MaxEntrySize { + fileData, err = os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("read file: %w", err) + } } exportedPath, err := m.ExportFileForFlatpak(filePath) if err != nil { exportedPath = filePath } - fileURI := "file://" + exportedPath + fileURI := encodeFileURI(exportedPath) - if imgData, imgMime, ok := m.tryReadImageFromURI([]byte("file://" + filePath)); ok { + if imgData, imgMime, ok := m.tryReadImageFromURI([]byte(encodeFileURI(filePath))); ok { entry := Entry{ Data: imgData, MimeType: imgMime, @@ -2169,10 +2194,16 @@ func (m *Manager) CopyFile(filePath string) error { log.Errorf("Failed to store file entry: %v", err) } } else { + entryData := fileData + if entryData == nil { + // Oversized: store the encoded URI instead of the body so the + // entry can be restored through EntryToFile later. + entryData = []byte(encodeFileURI(filePath) + "\r\n") + } entry := Entry{ - Data: fileData, + Data: entryData, MimeType: "text/uri-list", - Size: len(fileData), + Size: len(entryData), Timestamp: time.Now(), IsImage: false, Preview: fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)), @@ -2207,8 +2238,8 @@ func (m *Manager) EntryToFile(entry *Entry) string { return "" } uri := strings.TrimSuffix(strings.TrimSpace(lines[0]), "\r") - if path, ok := strings.CutPrefix(uri, "file://"); ok { - return path + if strings.HasPrefix(uri, "file://") { + return pathFromFileURI(uri) } case entry.IsImage: ext := ".png" diff --git a/core/internal/server/clipboard/manager_test.go b/core/internal/server/clipboard/manager_test.go index d1ed37d78..53456ad35 100644 --- a/core/internal/server/clipboard/manager_test.go +++ b/core/internal/server/clipboard/manager_test.go @@ -3,6 +3,8 @@ package clipboard import ( "bytes" "encoding/json" + "image" + "image/png" "net" "os" "path/filepath" @@ -1073,3 +1075,104 @@ func TestStoreEntry_CorruptDBReturnsErrorNotPanic(t *testing.T) { }) } } + +// The URI convention only holds if encode and decode are exact inverses for +// the path shapes users actually have. +func TestFileURIRoundTrip(t *testing.T) { + paths := []struct{ name, path string }{ + {"space", "/home/user/My File.txt"}, + {"literal percent-20", "/home/user/report%20final.fbx"}, + {"hash", "/home/user/notes #1/draft.txt"}, + {"cjk", "/home/user/文档/吉祥物 男女(2).fbx"}, + {"bare percent", "/home/user/100%.done"}, + } + for _, tc := range paths { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.path, pathFromFileURI(encodeFileURI(tc.path))) + }) + } +} + +// Oversized files are stored as a URI with no body. Storing that URI +// unencoded makes EntryToFile decode a path that was never encoded, so a +// literal "%20" in the name resolves to a different, missing file. +func TestCopyFile_OversizedEntryRoundTrips(t *testing.T) { + names := []string{"report%20final.fbx", "My File #1.fbx", "吉祥物 男女(2).fbx"} + for _, name := range names { + t.Run(name, func(t *testing.T) { + m := newTestManagerWithDB(t) + m.config.MaxEntrySize = 8 // force the nil fileData branch + + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, []byte("fbx-bytes-"+name), 0o644)) + + require.NoError(t, m.CopyFile(path)) + + history := m.GetHistory() + require.Len(t, history, 1) + require.Equal(t, "text/uri-list", history[0].MimeType) + // GetHistory only carries metadata; the stored body lives in GetEntry. + entry, err := m.GetEntry(history[0].ID) + require.NoError(t, err) + assert.Equal(t, path, m.EntryToFile(entry)) + }) + } +} + +// CopyFile hands the image probe a URI the probe then decodes; it must be an +// encoded one, or a name containing literal %20 probes the wrong path and the +// copy silently downgrades to a plain uri-list entry. +func TestCopyFile_EncodedImageNameStaysImage(t *testing.T) { + m := newTestManagerWithDB(t) + + path := filepath.Join(t.TempDir(), "抓拍%20name.png") + var buf bytes.Buffer + require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 4, 4)))) + require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644)) + + require.NoError(t, m.CopyFile(path)) + + history := m.GetHistory() + require.Len(t, history, 1) + require.True(t, history[0].IsImage) + assert.Equal(t, "image/png", history[0].MimeType) +} + +// The preview chain stats the path, so it must decode the encoded URIs +// file managers hand us while keeping a literal "%20" in the name intact. +func TestURIListPreview_DecodesEncodedURI(t *testing.T) { + m := newTestManagerWithDB(t) + + path := filepath.Join(t.TempDir(), "report%20final.fbx") + require.NoError(t, os.WriteFile(path, []byte("fbx-bytes"), 0o644)) + + preview, isImage := m.uriListPreview([]byte(encodeFileURI(path) + "\r\n")) + assert.Equal(t, "[[ file report%20final.fbx ]]", preview) + assert.False(t, isImage) +} + +// History restore runs handleCopyEntry -> EntryToFile -> CopyFile; the exact +// path must survive the whole chain, literal %20 included. +func TestHandleCopyEntry_OversizedFileEntryRestoresExactPath(t *testing.T) { + m := newTestManagerWithDB(t) + m.config.MaxEntrySize = 8 + + path := filepath.Join(t.TempDir(), "report%20final.fbx") + require.NoError(t, os.WriteFile(path, []byte("0123456789abcdef"), 0o644)) + require.NoError(t, m.CopyFile(path)) + + history := m.GetHistory() + require.Len(t, history, 1) + + mc := newClipboardTestConn() + handleCopyEntry(ipc.NewConnWriter(mc), ipc.Request{ + ID: 1, + Params: map[string]any{"id": float64(history[0].ID)}, + }, m) + + var resp ipc.Response[map[string]any] + require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp)) + require.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.Equal(t, path, (*resp.Result)["filePath"]) +}