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.
This commit is contained in:
尘世凡间
2026-10-03 03:30:53 -04:00
committed by GitHub
parent d9d9dc21d2
commit 204aa1735b
2 changed files with 149 additions and 15 deletions
+46 -15
View File
@@ -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"
@@ -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"])
}