Files
netbird/client/internal/filedrop/history.go
T
Zoltán Papp ff85ea0838 [client] Enforce a file drop block on a transfer already in flight
The receiving policy was read once, in submitOffer, and never again. The
upload path and the reader guarding it both watched only the offer's own
Decision, so blocking a sender refused its next offer while the transfer it
had already started kept streaming into the spool and was delivered.

SetSenderRule tried to settle the sender's offers but went through Decide,
which only moves an offer out of Pending, and skipped everything else -
precisely the accepted ones that were still running. It now revokes any
non-terminal offer instead, and the staged copy re-reads the policy on the
same cadence it already checks the decision, so a block stops the bytes.

State.terminal carries the state list that Transfer.terminal duplicated.
2026-09-08 20:48:28 +02:00

201 lines
4.9 KiB
Go

package filedrop
import (
"fmt"
"slices"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
// The transfer directions.
const (
DirectionReceived Direction = iota
DirectionSent
)
// The failure reasons a transfer can end with; None accompanies every other state.
const (
ReasonNone FailureReason = iota
// ReasonUnreachable marks a transport-level failure: nothing listens on the
// peer's file drop port, so the client is old or the tunnel is down.
ReasonUnreachable
// ReasonInterrupted marks a transfer that was still moving when the process
// stopped; nothing survived to finish or resume it.
ReasonInterrupted
)
const historyCap = 30
// Direction tells whether a transfer was sent by this device or received on it.
type Direction uint8
// FailureReason classifies why a transfer failed, when it is known.
type FailureReason uint8
// String implements fmt.Stringer.
func (d Direction) String() string {
switch d {
case DirectionReceived:
return "received"
case DirectionSent:
return "sent"
default:
return fmt.Sprintf("unknown(%d)", uint8(d))
}
}
// Transfer is one history entry: a sent or received offer with its outcome.
type Transfer struct {
ID OfferID `json:"id"`
Direction Direction `json:"direction"`
PeerKey PeerKey `json:"peerKey"`
PeerName string `json:"peerName"`
Files []FileMeta `json:"files"`
State State `json:"state"`
Transferred int64 `json:"transferred"`
TotalSize int64 `json:"totalSize"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeliveredPaths []string `json:"deliveredPaths,omitempty"`
Error string `json:"error,omitempty"`
Reason FailureReason `json:"reason,omitempty"`
}
// History is the persisted transfer log of one profile, newest first.
type History struct {
mu sync.RWMutex
store Store
entries []Transfer
}
// LoadHistory reads the stored transfer log, starting empty when unreadable.
func LoadHistory(store Store) *History {
h := &History{store: store}
var entries []Transfer
if err := loadSection(store, namespaceHistory, &entries); err != nil {
log.Warnf("failed to read file drop history, starting empty: %v", err)
return h
}
h.entries = entries
if h.settleInterrupted() {
h.persist()
}
return h
}
func (t Transfer) terminal() bool {
return t.State.terminal()
}
func (t Transfer) clone() Transfer {
c := t
c.Files = slices.Clone(t.Files)
c.DeliveredPaths = slices.Clone(t.DeliveredPaths)
return c
}
// Upsert inserts or replaces the entry with the same ID and persists the log.
func (h *History) Upsert(t Transfer) {
t.UpdatedAt = time.Now()
h.mu.Lock()
defer h.mu.Unlock()
if i := h.indexOf(t.ID); i >= 0 {
h.entries[i] = t
} else {
h.entries = slices.Insert(h.entries, 0, t)
h.prune()
}
h.persist()
}
// SetProgress updates the transferred byte count in memory only.
func (h *History) SetProgress(id OfferID, transferred int64) {
h.mu.Lock()
defer h.mu.Unlock()
if i := h.indexOf(id); i >= 0 {
h.entries[i].Transferred = transferred
h.entries[i].UpdatedAt = time.Now()
if h.entries[i].State == StatePending {
h.entries[i].State = StateTransferring
}
}
}
// Get returns the entry with the given ID.
func (h *History) Get(id OfferID) (Transfer, bool) {
h.mu.RLock()
defer h.mu.RUnlock()
if i := h.indexOf(id); i >= 0 {
return h.entries[i].clone(), true
}
return Transfer{}, false
}
// List returns every entry, newest first.
func (h *History) List() []Transfer {
h.mu.RLock()
defer h.mu.RUnlock()
list := make([]Transfer, len(h.entries))
for i, e := range h.entries {
list[i] = e.clone()
}
return list
}
// Delete removes one entry and persists the log.
func (h *History) Delete(id OfferID) {
h.mu.Lock()
defer h.mu.Unlock()
if i := h.indexOf(id); i >= 0 {
h.entries = slices.Delete(h.entries, i, i+1)
h.persist()
}
}
func (h *History) indexOf(id OfferID) int {
return slices.IndexFunc(h.entries, func(t Transfer) bool { return t.ID == id })
}
func (h *History) prune() {
if len(h.entries) <= historyCap {
return
}
for i := len(h.entries) - 1; i >= 0 && len(h.entries) > historyCap; i-- {
if h.entries[i].terminal() {
h.entries = slices.Delete(h.entries, i, i+1)
}
}
}
// settleInterrupted closes out transfers that were still moving when the
// process died. Nothing is left to finish them, so left alone they would sit in
// the log as permanently pending. Reports whether anything changed.
func (h *History) settleInterrupted() bool {
changed := false
for i, t := range h.entries {
if t.terminal() {
continue
}
h.entries[i].State = StateFailed
h.entries[i].Reason = ReasonInterrupted
h.entries[i].UpdatedAt = time.Now()
changed = true
}
return changed
}
func (h *History) persist() {
if err := saveSection(h.store, namespaceHistory, h.entries); err != nil {
log.Warnf("failed to write file drop history: %v", err)
}
}