Files
clip-sync/internal/dedup/dedup.go
T
bruno 5655573fc8
CI / Lint (push) Failing after 14m46s
CI / Test (push) Failing after 4m23s
CI / Build (darwin/amd64) (push) Skipped
CI / Build (linux/amd64) (push) Skipped
CI / Build (windows/amd64) (push) Skipped
CI / Build (darwin/arm64) (push) Skipped
CI / Build (linux/arm64) (push) Skipped
CI / Build (windows/arm64) (push) Skipped
CI / Release (push) Skipped
feat: implémentation complete v0.1.0 — daemon de sync clipboard bidirectionnel
2026-08-08 15:47:22 -04:00

103 lines
2.7 KiB
Go

// 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.
type Payload struct {
Text string `json:"text"`
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)
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.mu.Lock()
defer f.mu.Unlock()
now := time.Now().UnixNano()
hash := hashText(text)
// 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[:])
}