mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-28 10:31:29 +02:00
Files move directly between peers over the overlay, with no server in the path. The receiver listens on the WireGuard address only, so the port is unreachable from outside the tunnel, and every offer is matched to a known peer before anything is read. Consent is the default: an offer carries metadata alone, and no payload moves until the receiver accepts. Policy is per profile and device-local — off, ask, or auto-accept, with per-sender exceptions on top. Policy and history live in the profile's preferences, so removing a profile takes its file drop state with it. Transfers interrupted by a restart are settled on load; nothing survives to finish them, and left alone they would sit in the log as permanently pending. The Android bindings pull payload bytes through a chunk-returning stream: gomobile copies a []byte argument into a fresh Java array and never copies it back, so a fill-my-buffer method would hand back the right length with no data.
851 lines
27 KiB
Go
851 lines
27 KiB
Go
package filedrop
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
)
|
|
|
|
const (
|
|
testPeer = PeerKey("peer-pubkey")
|
|
testProfile = profilemanager.ID("test-profile")
|
|
)
|
|
|
|
type staticResolver struct {
|
|
key PeerKey
|
|
name string
|
|
unknown bool
|
|
}
|
|
|
|
func (r staticResolver) ResolvePeer(netip.Addr) (PeerKey, string, bool) {
|
|
if r.unknown {
|
|
return "", "", false
|
|
}
|
|
return r.key, r.name, true
|
|
}
|
|
|
|
type recordingNotifier struct {
|
|
mu sync.Mutex
|
|
offers []Offer
|
|
completed []Offer
|
|
failed []Offer
|
|
withdrawn []Offer
|
|
progress int
|
|
}
|
|
|
|
func (n *recordingNotifier) OnOffer(o Offer) {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
n.offers = append(n.offers, o)
|
|
}
|
|
|
|
func (n *recordingNotifier) OnProgress(Offer, int, int64) {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
n.progress++
|
|
}
|
|
|
|
func (n *recordingNotifier) OnCompleted(o Offer) {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
n.completed = append(n.completed, o)
|
|
}
|
|
|
|
func (n *recordingNotifier) OnFailed(o Offer, _ error) {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
n.failed = append(n.failed, o)
|
|
}
|
|
|
|
func (n *recordingNotifier) OnWithdrawn(o Offer) {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
n.withdrawn = append(n.withdrawn, o)
|
|
}
|
|
|
|
func (n *recordingNotifier) snapshot() (offers, completed, failed, withdrawn []Offer) {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
return append([]Offer(nil), n.offers...), append([]Offer(nil), n.completed...),
|
|
append([]Offer(nil), n.failed...), append([]Offer(nil), n.withdrawn...)
|
|
}
|
|
|
|
func startTestServer(t *testing.T, mode Mode, resolver PeerResolver) (*Server, *Client, *recordingNotifier) {
|
|
t.Helper()
|
|
|
|
policy := NewPolicyStore(testProfile)
|
|
require.NoError(t, policy.Set(Policy{Mode: mode}))
|
|
|
|
notifier := &recordingNotifier{}
|
|
srv, err := NewServer(ServerConfig{
|
|
SpoolDir: t.TempDir(),
|
|
Policy: policy,
|
|
Resolver: resolver,
|
|
Notifier: notifier,
|
|
OfferTTL: 5 * time.Second,
|
|
})
|
|
require.NoError(t, err, "server setup must succeed")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
require.NoError(t, srv.Start(ctx, netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0)))
|
|
t.Cleanup(func() {
|
|
require.NoError(t, srv.Stop())
|
|
})
|
|
|
|
srv.mu.RLock()
|
|
addr := srv.listener.Addr().String()
|
|
srv.mu.RUnlock()
|
|
|
|
client, err := NewClient(ClientConfig{
|
|
SenderName: "sender",
|
|
PollTimeout: 2 * time.Second,
|
|
OfferTimeout: 5 * time.Second,
|
|
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
|
var d net.Dialer
|
|
return d.DialContext(ctx, network, addr)
|
|
},
|
|
})
|
|
require.NoError(t, err, "client setup must succeed")
|
|
|
|
return srv, client, notifier
|
|
}
|
|
|
|
func filePayload(t *testing.T, name string, content []byte) Payload {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), name)
|
|
require.NoError(t, os.WriteFile(path, content, 0o600))
|
|
|
|
return Payload{
|
|
Meta: FileMeta{Name: name, Size: int64(len(content)), ContentType: "application/octet-stream"},
|
|
Open: func(offset int64) (io.ReadCloser, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
|
_ = f.Close()
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
var testAddr = netip.AddrPortFrom(netip.AddrFrom4([4]byte{100, 64, 0, 1}), Port)
|
|
|
|
func TestAutoAcceptTransfersPayload(t *testing.T) {
|
|
srv, client, notifier := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer, name: "laptop"})
|
|
|
|
content := []byte(strings.Repeat("netbird", 1000))
|
|
payload := filePayload(t, "report.bin", content)
|
|
|
|
var lastSent int64
|
|
id, err := client.Send(context.Background(), testAddr, []Payload{payload}, func(_ int, sent, _ int64) {
|
|
lastSent = sent
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, id, "receiver must return an offer id")
|
|
|
|
assert.Equal(t, int64(len(content)), lastSent, "progress must reach the full payload size")
|
|
|
|
staged, err := os.ReadFile(srv.Spool().Path(id, 0))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, content, staged, "staged bytes should match what was sent")
|
|
|
|
offer, ok := srv.Offers().Get(testPeer, id)
|
|
require.True(t, ok, "offer must still be tracked")
|
|
assert.Equal(t, StateCompleted, offer.State, "offer should be completed")
|
|
assert.Equal(t, "laptop", offer.SenderName, "sender name should come from the resolver")
|
|
|
|
_, completed, _, _ := notifier.snapshot()
|
|
require.Len(t, completed, 1, "one completion event expected")
|
|
assert.Equal(t, id, completed[0].ID)
|
|
}
|
|
|
|
func TestAskModeAcceptReleasesUpload(t *testing.T) {
|
|
srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer})
|
|
|
|
content := []byte("consent required")
|
|
payload := filePayload(t, "note.txt", content)
|
|
|
|
go func() {
|
|
for {
|
|
offers, _, _, _ := notifier.snapshot()
|
|
if len(offers) > 0 {
|
|
srv.Offers().Decide(offers[0].ID, DecisionAccepted)
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}()
|
|
|
|
id, err := client.Send(context.Background(), testAddr, []Payload{payload}, nil)
|
|
require.NoError(t, err)
|
|
|
|
staged, err := os.ReadFile(srv.Spool().Path(id, 0))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, content, staged, "payload should arrive after acceptance")
|
|
|
|
offers, _, _, _ := notifier.snapshot()
|
|
require.Len(t, offers, 1, "the pending offer must be raised exactly once")
|
|
assert.Equal(t, DecisionPending, offers[0].Decision, "the raised offer starts pending")
|
|
}
|
|
|
|
func TestAskModeDeclineKeepsPayloadOut(t *testing.T) {
|
|
srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer})
|
|
|
|
go func() {
|
|
for {
|
|
offers, _, _, _ := notifier.snapshot()
|
|
if len(offers) > 0 {
|
|
srv.Offers().Decide(offers[0].ID, DecisionDeclined)
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}()
|
|
|
|
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))
|
|
assert.True(t, os.IsNotExist(statErr), "declined payload must never be staged")
|
|
}
|
|
|
|
func TestOffModeRefusesOffer(t *testing.T) {
|
|
_, client, notifier := startTestServer(t, ModeOff, staticResolver{key: testPeer})
|
|
|
|
_, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
|
|
require.ErrorIs(t, err, ErrRefused, "an off receiver must refuse the offer")
|
|
|
|
offers, _, _, _ := notifier.snapshot()
|
|
assert.Empty(t, offers, "a refused offer must not reach the user")
|
|
}
|
|
|
|
func TestUnknownSenderIsRefused(t *testing.T) {
|
|
_, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{unknown: true})
|
|
|
|
_, err := client.Send(context.Background(), testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
|
|
require.ErrorIs(t, err, ErrRefused, "an unresolvable source address must be refused")
|
|
}
|
|
|
|
func TestUploadResumesFromConfirmedOffset(t *testing.T) {
|
|
srv, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
|
|
|
|
content := []byte(strings.Repeat("resume", 500))
|
|
payload := filePayload(t, "big.bin", content)
|
|
|
|
offer := srv.Offers().Add(testPeer, "", []FileMeta{payload.Meta}, DecisionAccepted)
|
|
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)
|
|
require.NoError(t, err)
|
|
|
|
srv.mu.RLock()
|
|
base := "http://" + srv.listener.Addr().String()
|
|
srv.mu.RUnlock()
|
|
|
|
confirmed, err := client.confirmedOffset(context.Background(), base, offer.ID, 0)
|
|
require.NoError(t, err)
|
|
require.Equal(t, half, confirmed, "receiver must report the staged prefix")
|
|
|
|
require.NoError(t, client.putFile(context.Background(), base, offer.ID, 0, payload, confirmed, nil))
|
|
|
|
staged, err := os.ReadFile(srv.Spool().Path(offer.ID, 0))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, content, staged, "resumed upload must reconstruct the full payload")
|
|
}
|
|
|
|
func TestUploadIsBoundedByAnnouncedSize(t *testing.T) {
|
|
srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
|
|
|
|
// The request is issued raw: the stdlib client refuses to send a body that
|
|
announced := int64(10)
|
|
offer := srv.Offers().Add(testPeer, "", []FileMeta{{Name: "lie.bin", Size: announced}}, DecisionAccepted)
|
|
require.NoError(t, srv.Spool().Prepare(offer.ID))
|
|
|
|
srv.mu.RLock()
|
|
addr := srv.listener.Addr().String()
|
|
srv.mu.RUnlock()
|
|
|
|
conn, err := net.Dial("tcp", addr)
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
oversized := strings.Repeat("A", 100)
|
|
request := "PUT /v1/offers/" + string(offer.ID) + "/files/0?offset=0 HTTP/1.1\r\n" +
|
|
"Host: filedrop\r\nContent-Length: 100\r\nConnection: close\r\n\r\n" + oversized
|
|
_, err = conn.Write([]byte(request))
|
|
require.NoError(t, err)
|
|
|
|
_, err = io.ReadAll(conn)
|
|
require.NoError(t, err)
|
|
|
|
staged, err := os.ReadFile(srv.Spool().Path(offer.ID, 0))
|
|
require.NoError(t, err)
|
|
assert.Len(t, staged, int(announced), "staged size must be capped at the announced size")
|
|
}
|
|
|
|
func TestCancelWithdrawsPendingOffer(t *testing.T) {
|
|
srv, client, notifier := startTestServer(t, ModeAsk, staticResolver{key: testPeer})
|
|
|
|
sendCtx, cancelSend := context.WithCancel(context.Background())
|
|
defer cancelSend()
|
|
|
|
go func() {
|
|
_, _ = client.Send(sendCtx, testAddr, []Payload{filePayload(t, "x.bin", []byte("data"))}, nil)
|
|
}()
|
|
|
|
var id OfferID
|
|
require.Eventually(t, func() bool {
|
|
offers, _, _, _ := notifier.snapshot()
|
|
if len(offers) == 0 {
|
|
return false
|
|
}
|
|
id = offers[0].ID
|
|
return true
|
|
}, 3*time.Second, 10*time.Millisecond, "offer must reach the receiver")
|
|
|
|
require.NoError(t, client.Cancel(context.Background(), testAddr, id))
|
|
|
|
_, ok := srv.Offers().Get(testPeer, id)
|
|
assert.False(t, ok, "a withdrawn offer must be dropped")
|
|
|
|
_, _, _, withdrawn := notifier.snapshot()
|
|
require.Len(t, withdrawn, 1, "the consent prompt must be withdrawn")
|
|
assert.Equal(t, id, withdrawn[0].ID)
|
|
}
|
|
|
|
func TestTextPayloadStaysInline(t *testing.T) {
|
|
srv, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
|
|
|
|
id, err := client.Send(context.Background(), testAddr, []Payload{TextPayload("snippet", "hello peer")}, nil)
|
|
require.NoError(t, err)
|
|
|
|
offer, ok := srv.Offers().Get(testPeer, id)
|
|
require.True(t, ok)
|
|
require.Len(t, offer.Files, 1)
|
|
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))
|
|
assert.True(t, os.IsNotExist(statErr), "text payloads must not be written to the spool")
|
|
}
|
|
|
|
func TestOfferExpiresWithoutDecision(t *testing.T) {
|
|
policy := NewPolicyStore(testProfile)
|
|
require.NoError(t, policy.SetMode(ModeAsk))
|
|
|
|
srv, err := NewServer(ServerConfig{
|
|
SpoolDir: t.TempDir(),
|
|
Policy: policy,
|
|
Resolver: staticResolver{key: testPeer},
|
|
OfferTTL: 100 * time.Millisecond,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
offer := srv.Offers().Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionPending)
|
|
|
|
awaited, err := srv.Offers().Await(context.Background(), testPeer, offer.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, DecisionExpired, awaited.Decision, "an unanswered offer must expire")
|
|
assert.Equal(t, StateExpired, awaited.State)
|
|
}
|
|
|
|
func TestDecideIsFinal(t *testing.T) {
|
|
store := NewOfferStore(time.Minute)
|
|
offer := store.Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionPending)
|
|
|
|
_, ok := store.Decide(offer.ID, DecisionDeclined)
|
|
require.True(t, ok, "the first decision must be recorded")
|
|
|
|
_, ok = store.Decide(offer.ID, DecisionAccepted)
|
|
assert.False(t, ok, "a decided offer must not be revived")
|
|
|
|
current, ok := store.Get(testPeer, offer.ID)
|
|
require.True(t, ok)
|
|
assert.Equal(t, DecisionDeclined, current.Decision, "the original decision must stand")
|
|
}
|
|
|
|
func TestOfferIsScopedToItsSender(t *testing.T) {
|
|
store := NewOfferStore(time.Minute)
|
|
offer := store.Add(testPeer, "", []FileMeta{{Name: "x", Size: 1}}, DecisionAccepted)
|
|
|
|
_, ok := store.Get("other-peer", offer.ID)
|
|
assert.False(t, ok, "another peer must not see the offer")
|
|
|
|
_, err := store.Await(context.Background(), "other-peer", offer.ID)
|
|
assert.ErrorIs(t, err, ErrOfferNotFound, "another peer must not poll the offer")
|
|
}
|
|
|
|
func TestPolicyEvaluation(t *testing.T) {
|
|
store := NewPolicyStore(testProfile)
|
|
require.NoError(t, store.Set(Policy{Mode: ModeAsk}))
|
|
|
|
assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "unknown senders are asked about")
|
|
|
|
require.NoError(t, store.SetSenderRule(testPeer, SenderRuleBlock))
|
|
assert.Equal(t, ModeOff, store.Evaluate(testPeer), "a blocked sender is refused")
|
|
|
|
require.NoError(t, store.SetSenderRule(testPeer, SenderRuleAlwaysAccept))
|
|
assert.Equal(t, ModeAutoAccept, store.Evaluate(testPeer), "an always-accept sender skips the prompt")
|
|
|
|
require.NoError(t, store.SetSenderRule(testPeer, SenderRuleDefault))
|
|
assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "clearing the rule restores the base mode")
|
|
}
|
|
|
|
func TestPolicyRejectsUnknownModeAndDeniesOnCorruptRule(t *testing.T) {
|
|
store := NewPolicyStore(testProfile)
|
|
|
|
require.Error(t, store.SetMode(Mode(200)), "an unknown mode must be rejected")
|
|
assert.Equal(t, ModeAsk, store.Get().Mode, "the rejected mode must not be applied")
|
|
|
|
require.NoError(t, store.SetSenderRule(testPeer, SenderRule(200)))
|
|
assert.Equal(t, ModeOff, store.Evaluate(testPeer), "an unrecognized rule must deny")
|
|
}
|
|
|
|
type memStore struct {
|
|
mu sync.Mutex
|
|
sections map[string][]byte
|
|
loadErr error
|
|
}
|
|
|
|
func newMemStore() *memStore {
|
|
return &memStore{sections: map[string][]byte{}}
|
|
}
|
|
|
|
func (s *memStore) Get(namespace string, v any) (bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.loadErr != nil {
|
|
return false, s.loadErr
|
|
}
|
|
raw, ok := s.sections[namespace]
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
return true, json.Unmarshal(raw, v)
|
|
}
|
|
|
|
func (s *memStore) Put(namespace string, v any) error {
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.sections[namespace] = raw
|
|
return nil
|
|
}
|
|
|
|
func TestPolicyIsScopedPerProfile(t *testing.T) {
|
|
work, home := profilemanager.ID("work"), profilemanager.ID("home")
|
|
workPrefs, homePrefs := newMemStore(), newMemStore()
|
|
|
|
workStore := LoadPolicyStore(work, workPrefs)
|
|
require.NoError(t, workStore.SetMode(ModeOff))
|
|
require.NoError(t, workStore.SetSenderRule(testPeer, SenderRuleBlock))
|
|
|
|
homeStore := LoadPolicyStore(home, homePrefs)
|
|
assert.Equal(t, ModeAsk, homeStore.Get().Mode, "another profile keeps the default mode")
|
|
assert.Equal(t, ModeAsk, homeStore.Evaluate(testPeer), "a block in one profile must not apply to another")
|
|
|
|
reloaded := LoadPolicyStore(work, workPrefs)
|
|
assert.Equal(t, ModeOff, reloaded.Get().Mode, "the profile's mode must survive a reload")
|
|
assert.Equal(t, ModeOff, reloaded.Evaluate(testPeer), "the profile's sender rule must survive a reload")
|
|
}
|
|
|
|
func TestPolicyFallsBackToDefaultsOnLoadFailure(t *testing.T) {
|
|
prefs := newMemStore()
|
|
prefs.loadErr = errors.New("store unavailable")
|
|
|
|
store := LoadPolicyStore(testProfile, prefs)
|
|
|
|
assert.Equal(t, ModeAsk, store.Get().Mode, "an unreadable policy must not open the device up")
|
|
assert.Equal(t, ModeAsk, store.Evaluate(testPeer), "the safe default applies to unknown senders")
|
|
}
|
|
|
|
func TestPolicyRejectsStoredInvalidModeOnLoad(t *testing.T) {
|
|
prefs := newMemStore()
|
|
require.NoError(t, prefs.Put(namespacePolicy, Policy{Mode: Mode(200)}))
|
|
|
|
store := LoadPolicyStore(testProfile, prefs)
|
|
|
|
assert.Equal(t, ModeAsk, store.Get().Mode, "a corrupted stored mode must fall back to the default")
|
|
}
|
|
|
|
func TestStoreKeepsPolicyAndHistoryApart(t *testing.T) {
|
|
prefs := newMemStore()
|
|
|
|
mgr, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir(), Store: prefs})
|
|
require.NoError(t, err)
|
|
require.NoError(t, mgr.Policy().SetMode(ModeAutoAccept))
|
|
require.NoError(t, mgr.SetDestinationDir("/tmp/received"))
|
|
mgr.history.Upsert(Transfer{ID: "offer-1", PeerKey: testPeer, State: StateCompleted})
|
|
require.NoError(t, mgr.Close())
|
|
|
|
reloaded, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir(), Store: prefs})
|
|
require.NoError(t, err)
|
|
defer func() { require.NoError(t, reloaded.Close()) }()
|
|
|
|
assert.Equal(t, ModeAutoAccept, reloaded.Policy().Get().Mode, "the policy must survive a reload")
|
|
assert.Equal(t, "/tmp/received", reloaded.DestinationDir(), "the destination must survive a reload")
|
|
require.Len(t, reloaded.Transfers(), 1, "the history must survive a reload")
|
|
assert.Equal(t, OfferID("offer-1"), reloaded.Transfers()[0].ID)
|
|
}
|
|
|
|
func TestHistoryDropsOldestTerminalEntriesOverCap(t *testing.T) {
|
|
history := LoadHistory(newMemStore())
|
|
|
|
for i := 0; i < historyCap+10; i++ {
|
|
history.Upsert(Transfer{ID: OfferID(fmt.Sprintf("offer-%d", i)), State: StateCompleted})
|
|
}
|
|
|
|
entries := history.List()
|
|
require.Len(t, entries, historyCap, "the log must stay bounded")
|
|
assert.Equal(t, OfferID(fmt.Sprintf("offer-%d", historyCap+9)), entries[0].ID, "the newest entry stays")
|
|
}
|
|
|
|
func TestHistoryKeepsLiveTransfersOverCap(t *testing.T) {
|
|
history := LoadHistory(newMemStore())
|
|
|
|
history.Upsert(Transfer{ID: "live", State: StateTransferring})
|
|
for i := 0; i < historyCap+5; i++ {
|
|
history.Upsert(Transfer{ID: OfferID(fmt.Sprintf("done-%d", i)), State: StateCompleted})
|
|
}
|
|
|
|
_, ok := history.Get("live")
|
|
assert.True(t, ok, "a transfer still running must not be pruned")
|
|
}
|
|
|
|
func TestHistorySettlesTransfersInterruptedByRestart(t *testing.T) {
|
|
store := newMemStore()
|
|
|
|
history := LoadHistory(store)
|
|
history.Upsert(Transfer{ID: "pending", State: StatePending})
|
|
history.Upsert(Transfer{ID: "moving", State: StateTransferring})
|
|
history.Upsert(Transfer{ID: "done", State: StateCompleted})
|
|
history.Upsert(Transfer{ID: "refused", State: StateDeclined})
|
|
|
|
// A fresh load stands in for the next process: nothing survives to finish
|
|
// whatever was still moving.
|
|
reloaded := LoadHistory(store)
|
|
|
|
for _, tc := range []struct {
|
|
id OfferID
|
|
state State
|
|
reason FailureReason
|
|
}{
|
|
{"pending", StateFailed, ReasonInterrupted},
|
|
{"moving", StateFailed, ReasonInterrupted},
|
|
{"done", StateCompleted, ReasonNone},
|
|
{"refused", StateDeclined, ReasonNone},
|
|
} {
|
|
entry, ok := reloaded.Get(tc.id)
|
|
require.True(t, ok, "entry %s must survive the reload", tc.id)
|
|
assert.Equal(t, tc.state, entry.State, "state of %s", tc.id)
|
|
assert.Equal(t, tc.reason, entry.Reason, "reason of %s", tc.id)
|
|
}
|
|
|
|
// The settled states are written back, so a third start sees them as final
|
|
// rather than settling them again.
|
|
third := LoadHistory(store)
|
|
entry, ok := third.Get("moving")
|
|
require.True(t, ok)
|
|
assert.Equal(t, StateFailed, entry.State)
|
|
}
|
|
|
|
func TestSpoolWriteTruncatesStaleTail(t *testing.T) {
|
|
spool, err := NewSpool(t.TempDir())
|
|
require.NoError(t, err)
|
|
|
|
id := OfferID("offer")
|
|
require.NoError(t, spool.Prepare(id))
|
|
|
|
_, 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)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(4), total, "staged size follows the resumed write")
|
|
|
|
staged, err := os.ReadFile(spool.Path(id, 0))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "AABB", string(staged), "stale bytes past the offset must be dropped")
|
|
}
|
|
|
|
func TestSpoolCleanupDropsStalePartials(t *testing.T) {
|
|
spool, err := NewSpool(t.TempDir())
|
|
require.NoError(t, err)
|
|
|
|
stale, fresh := OfferID("stale"), OfferID("fresh")
|
|
require.NoError(t, spool.Prepare(stale))
|
|
require.NoError(t, spool.Prepare(fresh))
|
|
|
|
old := time.Now().Add(-2 * time.Hour)
|
|
require.NoError(t, os.Chtimes(spool.OfferDir(stale), old, old))
|
|
|
|
spool.Cleanup(time.Hour, time.Now())
|
|
|
|
_, err = os.Stat(spool.OfferDir(stale))
|
|
assert.True(t, os.IsNotExist(err), "the stale offer dir must be removed")
|
|
|
|
_, err = os.Stat(spool.OfferDir(fresh))
|
|
assert.NoError(t, err, "a recent offer dir must survive")
|
|
}
|
|
|
|
func TestParseOfferPath(t *testing.T) {
|
|
tests := []struct {
|
|
path string
|
|
id OfferID
|
|
index int
|
|
hasIndex bool
|
|
wantErr bool
|
|
}{
|
|
{path: "/v1/offers/abc", id: "abc"},
|
|
{path: "/v1/offers/abc/files/3", id: "abc", index: 3, hasIndex: true},
|
|
{path: "/v1/offers/", wantErr: true},
|
|
{path: "/v1/offers/abc/files", wantErr: true},
|
|
{path: "/v1/offers/abc/other/1", wantErr: true},
|
|
{path: "/v1/offers/abc/files/-1", wantErr: true},
|
|
{path: "/v1/offers/abc/files/x", wantErr: true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.path, func(t *testing.T) {
|
|
id, index, hasIndex, err := parseOfferPath(tc.path)
|
|
if tc.wantErr {
|
|
assert.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.id, id)
|
|
assert.Equal(t, tc.index, index)
|
|
assert.Equal(t, tc.hasIndex, hasIndex)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateOffer(t *testing.T) {
|
|
assert.Error(t, validateOffer(nil), "an empty offer is invalid")
|
|
assert.Error(t, validateOffer([]FileMeta{{Name: "x", Size: -1}}), "a negative size is invalid")
|
|
assert.Error(t, validateOffer(make([]FileMeta, MaxOfferFiles+1)), "too many files is invalid")
|
|
assert.Error(t, validateOffer([]FileMeta{{
|
|
Name: "x", Kind: KindText, Text: strings.Repeat("a", MaxInlineTextSize+1),
|
|
}}), "oversized inline text is invalid")
|
|
|
|
assert.NoError(t, validateOffer([]FileMeta{{Name: "x", Size: 10}}))
|
|
}
|
|
|
|
func TestStopIsIdempotent(t *testing.T) {
|
|
srv, err := NewServer(ServerConfig{
|
|
SpoolDir: t.TempDir(),
|
|
Policy: NewPolicyStore(testProfile),
|
|
Resolver: staticResolver{key: testPeer},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, srv.Stop(), "stopping a server that never started is a no-op")
|
|
|
|
require.NoError(t, srv.Start(context.Background(), netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0)))
|
|
require.NoError(t, srv.Stop())
|
|
require.NoError(t, srv.Stop(), "the second stop must also be a no-op")
|
|
}
|
|
|
|
func TestStartRejectsSecondStart(t *testing.T) {
|
|
srv, err := NewServer(ServerConfig{
|
|
SpoolDir: t.TempDir(),
|
|
Policy: NewPolicyStore(testProfile),
|
|
Resolver: staticResolver{key: testPeer},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 0)
|
|
require.NoError(t, srv.Start(context.Background(), addr))
|
|
t.Cleanup(func() {
|
|
require.NoError(t, srv.Stop())
|
|
})
|
|
|
|
err = srv.Start(context.Background(), addr)
|
|
require.Error(t, err, "a running server must reject a second start")
|
|
|
|
srv.mu.RLock()
|
|
running := srv.httpServer != nil && srv.listener != nil
|
|
srv.mu.RUnlock()
|
|
assert.True(t, running, "the original listener must survive the rejected start")
|
|
}
|
|
|
|
func TestNewServerRequiresResolver(t *testing.T) {
|
|
_, err := NewServer(ServerConfig{SpoolDir: t.TempDir(), Policy: NewPolicyStore(testProfile)})
|
|
require.Error(t, err, "a server without peer resolution must not be constructed")
|
|
}
|
|
|
|
func TestNewServerRequiresPolicy(t *testing.T) {
|
|
_, err := NewServer(ServerConfig{SpoolDir: t.TempDir(), Resolver: staticResolver{key: testPeer}})
|
|
require.Error(t, err, "a server without a profile policy must not be constructed")
|
|
}
|
|
|
|
func TestNewClientRequiresDialer(t *testing.T) {
|
|
_, err := NewClient(ClientConfig{})
|
|
require.Error(t, err, "a client without a dialer must not be constructed")
|
|
}
|
|
|
|
func TestSendRejectsEmptyPayloadSet(t *testing.T) {
|
|
_, client, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: testPeer})
|
|
|
|
_, err := client.Send(context.Background(), testAddr, nil, nil)
|
|
assert.True(t, errors.Is(err, ErrInvalidOffer), "sending nothing is an invalid offer")
|
|
}
|
|
|
|
func TestServerFallsBackWhenPortBusy(t *testing.T) {
|
|
blocker, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err, "blocker listener must bind")
|
|
defer func() {
|
|
require.NoError(t, blocker.Close())
|
|
}()
|
|
busyPort := uint16(blocker.Addr().(*net.TCPAddr).Port)
|
|
|
|
srv, err := NewServer(ServerConfig{
|
|
SpoolDir: t.TempDir(),
|
|
Policy: NewPolicyStore(testProfile),
|
|
Resolver: staticResolver{key: testPeer},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), busyPort)
|
|
require.NoError(t, srv.Start(context.Background(), addr), "start must fall back instead of failing")
|
|
t.Cleanup(func() {
|
|
require.NoError(t, srv.Stop())
|
|
})
|
|
|
|
bound := srv.BoundPort()
|
|
assert.NotZero(t, bound, "fallback must report the bound port")
|
|
assert.NotEqual(t, busyPort, bound, "fallback must pick a different port")
|
|
}
|
|
|
|
func TestPortRegistryAwait(t *testing.T) {
|
|
reg := NewPortRegistry()
|
|
|
|
reg.Set(testPeer, 5000)
|
|
port, changed := reg.Await(context.Background(), testPeer, 0)
|
|
assert.True(t, changed, "known differing port must return immediately")
|
|
assert.Equal(t, uint16(5000), port)
|
|
|
|
go func() {
|
|
time.Sleep(50 * time.Millisecond)
|
|
reg.Set(testPeer, 5000)
|
|
}()
|
|
_, changed = reg.Await(context.Background(), testPeer, 5000)
|
|
assert.False(t, changed, "an advertisement equal to the used port must release the waiter as unchanged")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
_, changed = reg.Await(ctx, testPeer, 5000)
|
|
assert.False(t, changed, "timeout without advertisement must report unchanged")
|
|
}
|
|
|
|
// senderManager builds a send-only manager whose dialer reaches the test server only
|
|
// on realPort; other ports behave per defaultPortBehavior ("refuse" or "hang").
|
|
func senderManager(t *testing.T, serverAddr string, realPort uint16, defaultPortBehavior string) *Manager {
|
|
t.Helper()
|
|
|
|
mgr, err := NewManager(ManagerConfig{Profile: testProfile, DataDir: t.TempDir()})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
require.NoError(t, mgr.Close())
|
|
})
|
|
|
|
mgr.SetTunnel(func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
ap, err := netip.ParseAddrPort(addr)
|
|
require.NoError(t, err, "dialer must receive a valid addr")
|
|
if ap.Port() == realPort {
|
|
var d net.Dialer
|
|
return d.DialContext(ctx, network, serverAddr)
|
|
}
|
|
if defaultPortBehavior == "hang" {
|
|
<-ctx.Done()
|
|
return nil, ctx.Err()
|
|
}
|
|
return nil, &net.OpError{Op: "dial", Net: network, Err: errors.New("connection refused")}
|
|
}, "sender")
|
|
|
|
return mgr
|
|
}
|
|
|
|
func waitForState(t *testing.T, mgr *Manager, id OfferID, want State) {
|
|
t.Helper()
|
|
require.Eventually(t, func() bool {
|
|
tr, ok := mgr.history.Get(id)
|
|
return ok && tr.State == want
|
|
}, 10*time.Second, 20*time.Millisecond, "transfer must reach state %s", want)
|
|
}
|
|
|
|
func TestSendRetriesOnAdvertisedPort(t *testing.T) {
|
|
srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: PeerKey("sender-key")})
|
|
srv.mu.RLock()
|
|
serverAddr := srv.listener.Addr().String()
|
|
srv.mu.RUnlock()
|
|
realPort := srv.BoundPort()
|
|
|
|
mgr := senderManager(t, serverAddr, realPort, "refuse")
|
|
|
|
id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")})
|
|
require.NoError(t, err)
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
mgr.Ports().Set(testPeer, realPort)
|
|
|
|
waitForState(t, mgr, id, StateCompleted)
|
|
}
|
|
|
|
func TestSendAbortsHangingAttemptOnAdvertisedPort(t *testing.T) {
|
|
srv, _, _ := startTestServer(t, ModeAutoAccept, staticResolver{key: PeerKey("sender-key")})
|
|
srv.mu.RLock()
|
|
serverAddr := srv.listener.Addr().String()
|
|
srv.mu.RUnlock()
|
|
realPort := srv.BoundPort()
|
|
|
|
mgr := senderManager(t, serverAddr, realPort, "hang")
|
|
|
|
id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")})
|
|
require.NoError(t, err)
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
mgr.Ports().Set(testPeer, realPort)
|
|
|
|
waitForState(t, mgr, id, StateCompleted)
|
|
}
|
|
|
|
func TestSendFailsWhenSignalConfirmsUsedPort(t *testing.T) {
|
|
mgr := senderManager(t, "127.0.0.1:1", 1, "refuse")
|
|
|
|
id, err := mgr.Send(testPeer, "receiver", netip.AddrFrom4([4]byte{100, 64, 0, 9}), []Payload{TextPayload("t", "hello")})
|
|
require.NoError(t, err)
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
mgr.Ports().Set(testPeer, 0)
|
|
|
|
waitForState(t, mgr, id, StateFailed)
|
|
}
|