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"]) +}