[client] Report a partial file drop delivery instead of losing track of it

Delivery moved payloads one at a time and returned on the first failure, and
the caller discarded the paths it returned along with the error. Files that
had already been moved stayed in the destination while the history said the
transfer failed and recorded no path for them, so the user was told nothing
arrived and had nothing to go looking with.

Every item is attempted now, the error names the ones that did not make it,
and the paths that did land are kept on the entry. A transfer settled
elsewhere still keeps the outcome it has, which is what finishTransfer gave.

Delivered names also drop the characters that change how the rest of the name
renders - a U+202E override makes "gpj.exe" display as though it ended in
".jpg" - and are held to one filesystem entry with the extension preserved.
An over-long name used to be refused by the OS, which is the likeliest way to
reach the partial delivery above.
This commit is contained in:
Zoltán Papp
2026-09-08 21:22:43 +02:00
parent ab47d2a4bf
commit 23c88c8b9b
3 changed files with 149 additions and 3 deletions
+77
View File
@@ -14,6 +14,7 @@ import (
"sync"
"testing"
"time"
"unicode/utf8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -782,6 +783,82 @@ func TestSpoolCleanupDropsStalePartials(t *testing.T) {
assert.NoError(t, err, "a recent offer dir must survive")
}
func TestSanitizeFileNameStripsRenderingControls(t *testing.T) {
// U+202E makes a file manager render the rest of the name reversed, so
// "gpj.exe" reads as though it ended in ".jpg".
got := sanitizeFileName("\u202egpj.exe", 0)
assert.Equal(t, "gpj.exe", got, "a bidi override must not survive into the delivered name")
assert.NotContains(t, got, "\u202e")
assert.Equal(t, "report.pdf", sanitizeFileName("re\u200eport.pdf", 0),
"a bidi mark must be dropped without eating the rest of the name")
assert.Equal(t, "notes.txt", sanitizeFileName("no\x00tes.txt", 0),
"a NUL must be dropped rather than refused by the filesystem")
assert.Equal(t, "file-3", sanitizeFileName("\u202e\u2066", 3),
"a name that is nothing but controls falls back to the index")
}
func TestSanitizeFileNameBoundsTheNameLength(t *testing.T) {
long := strings.Repeat("a", 400) + ".txt"
got := sanitizeFileName(long, 0)
assert.LessOrEqual(t, len(got), maxDeliveredNameBytes, "the name must fit a filesystem entry")
assert.True(t, strings.HasSuffix(got, ".txt"), "the extension must survive truncation")
// A multibyte stem must not be cut through a rune.
multi := strings.Repeat("é", 300) + ".txt"
got = sanitizeFileName(multi, 0)
assert.LessOrEqual(t, len(got), maxDeliveredNameBytes)
assert.True(t, utf8.ValidString(got), "truncation must leave valid UTF-8")
assert.Equal(t, "short.txt", sanitizeFileName("short.txt", 0), "a normal name is untouched")
}
func TestDeliverReportsWhatLandedWhenOneItemFails(t *testing.T) {
spool, err := NewSpool(t.TempDir())
require.NoError(t, err)
destDir := t.TempDir()
store := NewOfferStore(time.Minute)
files := []FileMeta{
{Name: "first.txt", Size: 4},
{Name: "second.txt", Size: 4},
{Name: "third.txt", Size: 4},
}
offer := store.Add(testPeer, "sender", files, DecisionAccepted)
require.NoError(t, spool.Prepare(offer.ID))
// Stage every item but the middle one, which is the state a failed write
// leaves behind.
for _, i := range []int{0, 2} {
_, err = spool.Write(offer.ID, i, "", 0, strings.NewReader("data"), 4)
require.NoError(t, err)
}
full, ok := store.Get(testPeer, offer.ID)
require.True(t, ok)
delivered, derr := spool.Deliver(full, destDir)
require.Error(t, derr, "the missing item must be reported")
assert.Contains(t, derr.Error(), "second.txt", "the error must name what failed")
require.Len(t, delivered, 2, "the items that landed must be reported, not discarded")
for _, p := range delivered {
_, serr := os.Stat(p)
require.NoErrorf(t, serr, "a reported path must exist: %s", p)
}
names := map[string]bool{}
entries, err := os.ReadDir(destDir)
require.NoError(t, err)
for _, e := range entries {
names[e.Name()] = true
}
assert.True(t, names["first.txt"], "the item before the failure must be delivered")
assert.True(t, names["third.txt"], "the item after the failure must still be attempted")
}
func TestParseOfferPath(t *testing.T) {
tests := []struct {
path string