feat: implémentation complete v0.1.0 — daemon de sync clipboard bidirectionnel
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
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
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
// 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[:])
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package dedup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestShouldIgnoreOwnOrigin(t *testing.T) {
|
||||
f := NewFilter("machine-a")
|
||||
|
||||
p := Payload{
|
||||
Text: "hello",
|
||||
Ts: time.Now().UnixNano(),
|
||||
Origin: "machine-a", // same as our origin
|
||||
}
|
||||
|
||||
if !f.ShouldIgnore(p) {
|
||||
t.Error("should ignore payload from own origin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldIgnoreStaleTimestamp(t *testing.T) {
|
||||
f := NewFilter("machine-a")
|
||||
f.MarkWritten("previous text")
|
||||
|
||||
// Wait a tiny bit so the timestamp is definitely in the past.
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
p := Payload{
|
||||
Text: "hello",
|
||||
Ts: f.LastWriteTs() - 1, // older than our last write
|
||||
Origin: "machine-b",
|
||||
}
|
||||
|
||||
if !f.ShouldIgnore(p) {
|
||||
t.Error("should ignore payload with stale timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldIgnoreDuplicateHash(t *testing.T) {
|
||||
f := NewFilter("machine-a")
|
||||
f.MarkWritten("hello world")
|
||||
|
||||
p := Payload{
|
||||
Text: "hello world",
|
||||
Ts: time.Now().UnixNano(),
|
||||
Origin: "machine-b",
|
||||
}
|
||||
|
||||
if !f.ShouldIgnore(p) {
|
||||
t.Error("should ignore payload with recently written hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldAcceptNewContent(t *testing.T) {
|
||||
f := NewFilter("machine-a")
|
||||
f.MarkWritten("old content")
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
p := Payload{
|
||||
Text: "new content",
|
||||
Ts: time.Now().UnixNano(),
|
||||
Origin: "machine-b",
|
||||
}
|
||||
|
||||
if f.ShouldIgnore(p) {
|
||||
t.Error("should accept new content from other origin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheEviction(t *testing.T) {
|
||||
f := NewFilter("machine-a")
|
||||
|
||||
// Fill the cache with 20 different texts.
|
||||
for i := 0; i < defaultCacheSize; i++ {
|
||||
f.MarkWritten("text")
|
||||
}
|
||||
|
||||
// The latest write is "text" — it should be in cache.
|
||||
p := Payload{
|
||||
Text: "text",
|
||||
Ts: time.Now().UnixNano(),
|
||||
Origin: "machine-b",
|
||||
}
|
||||
if !f.ShouldIgnore(p) {
|
||||
t.Error("'text' should still be in cache after 20 writes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkWrittenUpdatesTimestamp(t *testing.T) {
|
||||
f := NewFilter("machine-a")
|
||||
|
||||
before := f.LastWriteTs()
|
||||
if before != 0 {
|
||||
t.Errorf("initial LastWriteTs = %d, want 0", before)
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
f.MarkWritten("some text")
|
||||
|
||||
after := f.LastWriteTs()
|
||||
if after <= before {
|
||||
t.Errorf("LastWriteTs not updated: %d <= %d", after, before)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user