// Package dedup provides the anti-ping-pong (deduplication) filter for clip-sync. // // Two guardrails prevent sync loops: // 1. Content hash cache — an LRU ring buffer of the last N SHA-256 hashes // of locally written text. If we receive text whose hash matches a recent // local write, we ignore it (our own content echoed back through a peer). // 2. Origin + timestamp — each payload carries an origin hostname and a // nanosecond timestamp. If origin == our hostname (rebound via a third // peer) or if the timestamp is not newer than our last write, we ignore. package dedup import ( "crypto/sha256" "encoding/hex" "sync" "time" ) const defaultCacheSize = 20 // Filter prevents clipboard sync loops. type Filter struct { mu sync.Mutex origin string // our hostname cache []string // ring buffer of content hashes cacheIdx int // write position in ring buffer lastWriteTs int64 // nanosecond timestamp of our last local write } // NewFilter creates a new deduplication filter for the given origin hostname. func NewFilter(origin string) *Filter { return &Filter{ origin: origin, cache: make([]string, 0, defaultCacheSize), } } // Payload represents an incoming clip payload from a peer. It carries either // text (Text) or a base64-encoded image (Mime + Data). type Payload struct { Text string `json:"text"` Mime string `json:"mime,omitempty"` Data string `json:"data,omitempty"` // base64-encoded image bytes Ts int64 `json:"ts"` Origin string `json:"origin"` } // ShouldIgnore returns true if the payload should NOT be written to the local clipboard. func (f *Filter) ShouldIgnore(p Payload) bool { f.mu.Lock() defer f.mu.Unlock() // Guard 1: ignore our own origin (rebound via a third peer). if p.Origin == f.origin { return true } // Guard 2: ignore stale timestamps (not newer than our last write). if p.Ts <= f.lastWriteTs { return true } // Guard 3: ignore content we recently wrote (hash match). hash := hashText(p.Text + "\x00" + p.Data) for _, h := range f.cache { if h == hash { return true } } return false } // MarkWritten records that we just wrote text to the local clipboard. // This populates the hash cache and updates the last-write timestamp. func (f *Filter) MarkWritten(text string) { f.markWritten(text) } // MarkWrittenPayload records that we just wrote a payload (text or image) to // the local clipboard, so echoes of it are ignored. func (f *Filter) MarkWrittenPayload(p Payload) { f.markWritten(p.Text + "\x00" + p.Data) } func (f *Filter) markWritten(content string) { f.mu.Lock() defer f.mu.Unlock() now := time.Now().UnixNano() hash := hashText(content) // Ring buffer: overwrite oldest entry when full. if len(f.cache) < defaultCacheSize { f.cache = append(f.cache, hash) } else { f.cache[f.cacheIdx] = hash f.cacheIdx = (f.cacheIdx + 1) % defaultCacheSize } f.lastWriteTs = now } // LastWriteTs returns the timestamp of our last local write. func (f *Filter) LastWriteTs() int64 { f.mu.Lock() defer f.mu.Unlock() return f.lastWriteTs } // hashText returns the hex-encoded SHA-256 of the given text. func hashText(text string) string { h := sha256.Sum256([]byte(text)) return hex.EncodeToString(h[:]) }