[client] Stage incoming file drop payloads through a platform sink

Android cannot address the user's shared storage by path, so a received
file had to land in app-private storage and be copied out afterwards,
needing twice the space of the transfer.

Put the staging area behind a Sink interface the receiver writes every
payload through. The filesystem spool implements it unchanged and stays
the default; a platform that cannot be addressed by path implements the
gomobile-bound half instead and stages payloads wherever it can reach.

The writer reports its own total rather than returning a written count:
gomobile copies a []byte argument into a fresh Java array and carries no
count back out.

A failed delivery now drops the staged payloads. The filesystem spool
swept them up on its next pass, but a sink holding entries the engine
cannot address by path has no such fallback.
This commit is contained in:
Zoltán Papp
2026-08-25 18:45:38 +02:00
parent b165e0126e
commit 861e0d5609
11 changed files with 518 additions and 26 deletions
+9 -9
View File
@@ -166,7 +166,7 @@ func TestAutoAcceptTransfersPayload(t *testing.T) {
assert.Equal(t, int64(len(content)), lastSent, "progress must reach the full payload size")
staged, err := os.ReadFile(srv.Spool().Path(id, 0))
staged, err := os.ReadFile(srv.FileSpool().Path(id, 0))
require.NoError(t, err)
assert.Equal(t, content, staged, "staged bytes should match what was sent")
@@ -200,7 +200,7 @@ func TestAskModeAcceptReleasesUpload(t *testing.T) {
id, err := client.Send(context.Background(), testAddr, []Payload{payload}, nil)
require.NoError(t, err)
staged, err := os.ReadFile(srv.Spool().Path(id, 0))
staged, err := os.ReadFile(srv.FileSpool().Path(id, 0))
require.NoError(t, err)
assert.Equal(t, content, staged, "payload should arrive after acceptance")
@@ -226,7 +226,7 @@ func TestAskModeDeclineKeepsPayloadOut(t *testing.T) {
id, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
require.ErrorIs(t, err, ErrDeclined, "sender must see the decline")
_, statErr := os.Stat(srv.Spool().Path(id, 0))
_, statErr := os.Stat(srv.FileSpool().Path(id, 0))
assert.True(t, os.IsNotExist(statErr), "declined payload must never be staged")
}
@@ -257,7 +257,7 @@ func TestUploadResumesFromConfirmedOffset(t *testing.T) {
require.NoError(t, srv.Spool().Prepare(offer.ID))
half := int64(len(content) / 2)
_, err := srv.Spool().Write(offer.ID, 0, 0, strings.NewReader(string(content[:half])), half)
_, err := srv.Spool().Write(offer.ID, 0, "", 0, strings.NewReader(string(content[:half])), half)
require.NoError(t, err)
srv.mu.RLock()
@@ -270,7 +270,7 @@ func TestUploadResumesFromConfirmedOffset(t *testing.T) {
require.NoError(t, client.putFile(context.Background(), base, offer.ID, 0, payload, confirmed, nil))
staged, err := os.ReadFile(srv.Spool().Path(offer.ID, 0))
staged, err := os.ReadFile(srv.FileSpool().Path(offer.ID, 0))
require.NoError(t, err)
assert.Equal(t, content, staged, "resumed upload must reconstruct the full payload")
}
@@ -302,7 +302,7 @@ func TestUploadIsBoundedByAnnouncedSize(t *testing.T) {
_, err = io.ReadAll(conn)
require.NoError(t, err)
staged, err := os.ReadFile(srv.Spool().Path(offer.ID, 0))
staged, err := os.ReadFile(srv.FileSpool().Path(offer.ID, 0))
require.NoError(t, err)
assert.Len(t, staged, int(announced), "staged size must be capped at the announced size")
}
@@ -349,7 +349,7 @@ func TestTextPayloadStaysInline(t *testing.T) {
assert.Equal(t, "hello peer", offer.Files[0].Text, "text must arrive in the offer itself")
assert.Equal(t, StateCompleted, offer.State, "a text-only offer completes without an upload")
_, statErr := os.Stat(srv.Spool().Path(id, 0))
_, statErr := os.Stat(srv.FileSpool().Path(id, 0))
assert.True(t, os.IsNotExist(statErr), "text payloads must not be written to the spool")
}
@@ -583,10 +583,10 @@ func TestSpoolWriteTruncatesStaleTail(t *testing.T) {
id := OfferID("offer")
require.NoError(t, spool.Prepare(id))
_, err = spool.Write(id, 0, 0, strings.NewReader("AAAAAAAAAA"), 10)
_, err = spool.Write(id, 0, "", 0, strings.NewReader("AAAAAAAAAA"), 10)
require.NoError(t, err)
total, err := spool.Write(id, 0, 2, strings.NewReader("BB"), 10)
total, err := spool.Write(id, 0, "", 2, strings.NewReader("BB"), 10)
require.NoError(t, err)
assert.Equal(t, int64(4), total, "staged size follows the resumed write")
+14 -2
View File
@@ -50,6 +50,7 @@ type ManagerConfig struct {
Store Store
Events EventSink
OfferTTL time.Duration
Sink Sink
}
type sendHandle struct {
@@ -69,6 +70,7 @@ type Manager struct {
history *History
events EventSink
offerTTL time.Duration
sink Sink
server *Server
ports *PortRegistry
@@ -95,6 +97,7 @@ func NewManager(cfg ManagerConfig) (*Manager, error) {
history: LoadHistory(cfg.Store),
events: cfg.Events,
offerTTL: cfg.OfferTTL,
sink: cfg.Sink,
ports: NewPortRegistry(),
sends: make(map[OfferID]*sendHandle),
}
@@ -141,8 +144,13 @@ func (m *Manager) DeleteTransfer(id OfferID) {
m.history.Delete(id)
}
// DestinationDir returns the directory received files are delivered to.
// DestinationDir returns where received files are delivered: the platform
// sink's own name for it when one stages payloads, otherwise the configured
// directory.
func (m *Manager) DestinationDir() string {
if labeller, ok := m.sink.(interface{ DestinationLabel() string }); ok {
return labeller.DestinationLabel()
}
return m.policy.DestinationDir()
}
@@ -162,6 +170,7 @@ func (m *Manager) StartReceiver(ctx context.Context, addr netip.AddrPort, netsta
server, err := NewServer(ServerConfig{
SpoolDir: filepath.Join(m.dataDir, "spool"),
Sink: m.sink,
Policy: m.policy,
Resolver: resolver,
Notifier: m,
@@ -577,9 +586,12 @@ func (m *Manager) OnCompleted(offer Offer) {
return
}
delivered, err := deliver(server.Spool(), offer, m.policy.DestinationDir())
delivered, err := server.Spool().Deliver(offer, m.policy.DestinationDir())
if err != nil {
log.Errorf("failed to deliver file drop payloads: %v", err)
// Nothing will ask for these payloads again, and a sink that cannot
// address them by path has no sweep of its own to fall back on.
server.Spool().Remove(offer.ID)
m.finishTransfer(offer.ID, StateFailed, err.Error())
m.emit(EventFailed, m.transferOf(offer.ID))
return
+3 -3
View File
@@ -27,11 +27,11 @@ type receiver struct {
resolver PeerResolver
notifier Notifier
offers *OfferStore
spool *Spool
spool Sink
spoolMaxAge time.Duration
}
func newReceiver(cfg ServerConfig, spool *Spool, maxAge time.Duration) *receiver {
func newReceiver(cfg ServerConfig, spool Sink, maxAge time.Duration) *receiver {
return &receiver{
policy: cfg.Policy,
resolver: cfg.Resolver,
@@ -162,7 +162,7 @@ func (r *receiver) upload(sender senderIdentity, id OfferID, index int, offset i
},
}
received, err := r.spool.Write(id, index, offset, staged, size)
received, err := r.spool.Write(id, index, offer.Files[index].Name, offset, staged, size)
// A withdrawn offer is an answer, not a failure: the state it moved to is
// the one the user chose, and the spool is already gone.
+19 -5
View File
@@ -35,9 +35,12 @@ type Notifier interface {
OnWithdrawn(offer Offer)
}
// ServerConfig configures the receiving side.
// ServerConfig configures the receiving side. Sink overrides the filesystem
// spool, for a platform that stages payloads somewhere the engine cannot
// address; SpoolDir is then unused.
type ServerConfig struct {
SpoolDir string
Sink Sink
Policy *PolicyStore
Resolver PeerResolver
Notifier Notifier
@@ -70,9 +73,13 @@ func NewServer(cfg ServerConfig) (*Server, error) {
return nil, errors.New("receiving policy is required")
}
spool, err := NewSpool(cfg.SpoolDir)
if err != nil {
return nil, fmt.Errorf("create spool: %w", err)
spool := cfg.Sink
if spool == nil {
fsSpool, err := NewSpool(cfg.SpoolDir)
if err != nil {
return nil, fmt.Errorf("create spool: %w", err)
}
spool = fsSpool
}
maxAge := cfg.SpoolMaxAge
@@ -96,10 +103,17 @@ func (s *Server) Offers() *OfferStore {
}
// Spool returns the staging area, so the platform layer can deliver completed payloads.
func (s *Server) Spool() *Spool {
func (s *Server) Spool() Sink {
return s.recv.spool
}
// FileSpool returns the staging area as a filesystem spool, nil when the
// platform staged payloads elsewhere.
func (s *Server) FileSpool() *Spool {
spool, _ := s.recv.spool.(*Spool)
return spool
}
// Policy returns the active profile's receiving policy store.
func (s *Server) Policy() *PolicyStore {
return s.recv.policy
+135
View File
@@ -0,0 +1,135 @@
package filedrop
import (
"fmt"
"io"
"strings"
"time"
)
// Sink stages incoming payloads until an offer completes. The receiver writes
// every payload through it and never touches the filesystem directly, so a
// platform whose destination is not a filesystem path can implement its own.
type Sink interface {
Prepare(id OfferID) error
Received(id OfferID, index int) (int64, error)
Write(id OfferID, index int, name string, offset int64, r io.Reader, limit int64) (int64, error)
Deliver(offer Offer, destDir string) ([]string, error)
Remove(id OfferID)
Cleanup(maxAge time.Duration, now time.Time)
}
// PlatformSink is the platform-facing half of a Sink, bound over gomobile. It
// carries the same calls with only types gomobile can bind, so a platform can
// stage payloads somewhere the engine cannot address by path, such as an
// Android MediaStore entry.
type PlatformSink interface {
// DestinationLabel names where payloads land, for the UI to show in place of
// a path the platform does not have.
DestinationLabel() string
Prepare(offerID string) error
Received(offerID string, index int) (int64, error)
OpenWriter(offerID string, index int, name string, offset int64, size int64) (PlatformWriter, error)
// Deliver publishes an offer's payloads and returns where each one landed,
// newline-separated, in the order the offer announced them, text payloads
// skipped.
Deliver(offerID string) (string, error)
Remove(offerID string)
Cleanup(maxAgeSeconds int64)
}
// PlatformWriter is one payload's destination, opened by a PlatformSink.
//
// WriteChunk takes the bytes by value rather than filling a caller-supplied
// buffer: gomobile copies a []byte argument into a fresh Java array, which is
// the right direction here, but the written count never crosses back, so the
// writer reports its own total through Written instead, read after Close.
type PlatformWriter interface {
WriteChunk(p []byte) error
Close() error
Written() int64
}
// platformSink adapts a PlatformSink to the Sink the receiver uses.
type platformSink struct {
platform PlatformSink
}
// platformWriter adapts a PlatformWriter to io.Writer.
type platformWriter struct {
w PlatformWriter
}
// NewPlatformSink wraps a platform-provided sink for the receiver to stage into.
func NewPlatformSink(platform PlatformSink) (Sink, error) {
if platform == nil {
return nil, fmt.Errorf("platform sink is required")
}
return &platformSink{platform: platform}, nil
}
// DestinationLabel reports where the platform delivers payloads.
func (s *platformSink) DestinationLabel() string {
return s.platform.DestinationLabel()
}
func (s *platformSink) Prepare(id OfferID) error {
return s.platform.Prepare(string(id))
}
func (s *platformSink) Received(id OfferID, index int) (int64, error) {
return s.platform.Received(string(id), index)
}
func (s *platformSink) Write(id OfferID, index int, name string, offset int64, r io.Reader, limit int64) (int64, error) {
if offset < 0 {
return 0, fmt.Errorf("negative offset %d", offset)
}
w, err := s.platform.OpenWriter(string(id), index, name, offset, limit)
if err != nil {
return offset, fmt.Errorf("open destination: %w", err)
}
_, copyErr := io.Copy(&platformWriter{w: w}, io.LimitReader(r, limit-offset))
closeErr := w.Close()
written := w.Written()
if copyErr != nil {
return written, fmt.Errorf("write destination: %w", copyErr)
}
if closeErr != nil {
return written, fmt.Errorf("close destination: %w", closeErr)
}
return written, nil
}
func (s *platformSink) Deliver(offer Offer, _ string) ([]string, error) {
delivered, err := s.platform.Deliver(string(offer.ID))
if err != nil {
return nil, err
}
return splitDelivered(delivered), nil
}
func (s *platformSink) Remove(id OfferID) {
s.platform.Remove(string(id))
}
func (s *platformSink) Cleanup(maxAge time.Duration, _ time.Time) {
s.platform.Cleanup(int64(maxAge / time.Second))
}
func (p *platformWriter) Write(b []byte) (int, error) {
if err := p.w.WriteChunk(b); err != nil {
return 0, err
}
return len(b), nil
}
func splitDelivered(delivered string) []string {
if delivered == "" {
return nil
}
return strings.Split(delivered, "\n")
}
+219
View File
@@ -0,0 +1,219 @@
package filedrop
import (
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakePlatformWriter struct {
written []byte
offset int64
closed bool
writeErr error
closeErr error
}
type fakePlatformSink struct {
writers map[string]*fakePlatformWriter
prepared []string
removed []string
cleanups []int64
openErr error
closeErr error
openCalls []string
delivered string
label string
}
func newFakePlatformSink() *fakePlatformSink {
return &fakePlatformSink{writers: map[string]*fakePlatformWriter{}}
}
func (w *fakePlatformWriter) WriteChunk(p []byte) error {
if w.writeErr != nil {
return w.writeErr
}
w.written = append(w.written, p...)
return nil
}
func (w *fakePlatformWriter) Close() error {
w.closed = true
return w.closeErr
}
func (w *fakePlatformWriter) Written() int64 {
return w.offset + int64(len(w.written))
}
func (s *fakePlatformSink) DestinationLabel() string {
return s.label
}
func (s *fakePlatformSink) Prepare(offerID string) error {
s.prepared = append(s.prepared, offerID)
return nil
}
func (s *fakePlatformSink) Received(offerID string, index int) (int64, error) {
w, ok := s.writers[key(offerID, index)]
if !ok {
return 0, nil
}
return w.Written(), nil
}
func (s *fakePlatformSink) OpenWriter(offerID string, index int, name string, offset int64, _ int64) (PlatformWriter, error) {
s.openCalls = append(s.openCalls, name)
if s.openErr != nil {
return nil, s.openErr
}
w := &fakePlatformWriter{offset: offset, closeErr: s.closeErr}
s.writers[key(offerID, index)] = w
return w, nil
}
func (s *fakePlatformSink) Deliver(string) (string, error) {
return s.delivered, nil
}
func (s *fakePlatformSink) Remove(offerID string) {
s.removed = append(s.removed, offerID)
}
func (s *fakePlatformSink) Cleanup(maxAgeSeconds int64) {
s.cleanups = append(s.cleanups, maxAgeSeconds)
}
func key(offerID string, index int) string {
return fmt.Sprintf("%s:%d", offerID, index)
}
func TestPlatformSinkWritesThroughToTheDestination(t *testing.T) {
platform := newFakePlatformSink()
sink, err := NewPlatformSink(platform)
require.NoError(t, err)
written, err := sink.Write("offer", 0, "report.bin", 0, strings.NewReader("netbird"), 7)
require.NoError(t, err)
assert.Equal(t, int64(7), written)
w := platform.writers[key("offer", 0)]
assert.Equal(t, "netbird", string(w.written))
assert.True(t, w.closed, "the destination must be closed after the write")
assert.Equal(t, []string{"report.bin"}, platform.openCalls,
"the announced name must reach the platform, which owns the final naming")
}
func TestPlatformSinkStopsAtTheAnnouncedSize(t *testing.T) {
platform := newFakePlatformSink()
sink, err := NewPlatformSink(platform)
require.NoError(t, err)
written, err := sink.Write("offer", 0, "x.bin", 0, strings.NewReader("more than announced"), 4)
require.NoError(t, err)
assert.Equal(t, int64(4), written, "a sender overrunning its own offer must be cut off")
assert.Equal(t, "more", string(platform.writers[key("offer", 0)].written))
}
func TestPlatformSinkResumeReportsTheTotal(t *testing.T) {
platform := newFakePlatformSink()
sink, err := NewPlatformSink(platform)
require.NoError(t, err)
written, err := sink.Write("offer", 0, "x.bin", 3, strings.NewReader("def"), 6)
require.NoError(t, err)
assert.Equal(t, int64(6), written,
"the platform counts from the offset it resumed at, not from this call alone")
}
func TestPlatformSinkFailsWhenTheDestinationWillNotOpen(t *testing.T) {
platform := newFakePlatformSink()
platform.openErr = errors.New("no space")
sink, err := NewPlatformSink(platform)
require.NoError(t, err)
written, err := sink.Write("offer", 0, "x.bin", 5, strings.NewReader("data"), 9)
require.Error(t, err)
assert.Equal(t, int64(5), written, "the offset already staged survives a failed reopen")
}
func TestPlatformSinkReportsWhatLandedWhenTheCloseFails(t *testing.T) {
platform := newFakePlatformSink()
sink, err := NewPlatformSink(platform)
require.NoError(t, err)
// A destination that accepts every chunk but fails to flush has to report the
// bytes that did land, or a resume would restart from the wrong offset.
platform.closeErr = errors.New("flush failed")
written, err := sink.Write("offer", 0, "x.bin", 0, strings.NewReader("netbird"), 7)
require.Error(t, err)
assert.Equal(t, int64(7), written)
}
func TestPlatformSinkRejectsNegativeOffset(t *testing.T) {
sink, err := NewPlatformSink(newFakePlatformSink())
require.NoError(t, err)
_, err = sink.Write("offer", 0, "x.bin", -1, strings.NewReader("data"), 4)
require.Error(t, err)
}
func TestPlatformSinkDeliverSplitsTheReportedDestinations(t *testing.T) {
platform := newFakePlatformSink()
platform.delivered = "content://media/1\ncontent://media/2"
sink, err := NewPlatformSink(platform)
require.NoError(t, err)
delivered, err := sink.Deliver(Offer{ID: "offer"}, "")
require.NoError(t, err)
assert.Equal(t, []string{"content://media/1", "content://media/2"}, delivered)
}
func TestPlatformSinkDeliverWithoutDestinationsIsEmpty(t *testing.T) {
sink, err := NewPlatformSink(newFakePlatformSink())
require.NoError(t, err)
delivered, err := sink.Deliver(Offer{ID: "offer"}, "")
require.NoError(t, err)
assert.Empty(t, delivered, "a text-only offer delivers nothing, rather than one empty path")
}
func TestPlatformSinkCleanupPassesWholeSeconds(t *testing.T) {
platform := newFakePlatformSink()
sink, err := NewPlatformSink(platform)
require.NoError(t, err)
sink.Cleanup(90*time.Minute, time.Now())
assert.Equal(t, []int64{5400}, platform.cleanups)
}
func TestNewPlatformSinkRequiresAPlatform(t *testing.T) {
_, err := NewPlatformSink(nil)
require.Error(t, err)
}
func TestManagerReportsThePlatformDestinationLabel(t *testing.T) {
platform := newFakePlatformSink()
platform.label = "Download/NetBird"
sink, err := NewPlatformSink(platform)
require.NoError(t, err)
mgr, err := NewManager(ManagerConfig{
Profile: testProfile,
DataDir: t.TempDir(),
Sink: sink,
})
require.NoError(t, err)
require.NoError(t, mgr.SetDestinationDir("/data/data/io.netbird.client/files"))
assert.Equal(t, "Download/NetBird", mgr.DestinationDir(),
"the platform's own name for the destination wins over the stored path")
}
+10 -2
View File
@@ -61,8 +61,10 @@ func (s *Spool) Received(id OfferID, index int) (int64, error) {
return info.Size(), nil
}
// Write appends the payload at offset, truncating any bytes past it first.
func (s *Spool) Write(id OfferID, index int, offset int64, r io.Reader, limit int64) (int64, error) {
// Write appends the payload at offset, truncating any bytes past it first. The
// announced name plays no part: payloads are staged under their index and only
// take their name in Deliver.
func (s *Spool) Write(id OfferID, index int, _ string, offset int64, r io.Reader, limit int64) (int64, error) {
if offset < 0 {
return 0, fmt.Errorf("negative offset %d", offset)
}
@@ -92,6 +94,12 @@ func (s *Spool) Write(id OfferID, index int, offset int64, r io.Reader, limit in
return offset + written, nil
}
// Deliver moves an offer's staged payloads into destDir under their announced
// names and returns where each one landed.
func (s *Spool) Deliver(offer Offer, destDir string) ([]string, error) {
return deliver(s, offer, destDir)
}
// Path returns the staged path of one item for the platform layer to deliver from.
func (s *Spool) Path(id OfferID, index int) string {
return s.filePath(id, index)