Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/sync-conflicts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Sync conflict resolution

## Goals
- Keep local notes safe during sync.
- Prefer the most recent write.
- Preserve conflicting edits in deterministic copies.

## Conflict detection
1. Each note stores its last modified time from the filesystem.
2. During sync, compare the incoming remote modified time to the local modified time.
3. A conflict exists when **both sides changed** since the last sync checkpoint.

## Resolution policy (last-write-wins + conflict copies)
1. If only one side changed, accept that change.
2. If both sides changed:
- The version with the newest modified time becomes the primary note (last-write-wins).
- The losing version is written as a conflict copy alongside the primary note.

## Conflict copy naming
Conflict copies are deterministic and derived from the losing version metadata:

```
<original filename>.conflict-<UTC timestamp>-<actor id>
```

Example:

```
journal.txt.conflict-20240506T070809Z-device-01
```

Where:
- `UTC timestamp` is formatted as `YYYYMMDDTHHMMSSZ` from the losing write's modified time.
- `actor id` is the sync client identifier that performed the losing write (defaults to `unknown` when not available).

## File placement
Conflict copies are created in the same directory as the original note so users can discover and resolve them locally.
16 changes: 16 additions & 0 deletions sync_conflicts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package main

import (
"fmt"
"path/filepath"
"time"
)

func conflictCopyPath(originalPath string, conflictAt time.Time, actorID string) string {
if actorID == "" {
actorID = "unknown"
}
timestamp := conflictAt.UTC().Format("20060102T150405Z")
filename := fmt.Sprintf("%s.conflict-%s-%s", filepath.Base(originalPath), timestamp, actorID)
return filepath.Join(filepath.Dir(originalPath), filename)
Comment on lines +13 to +15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Sanitize actorID to prevent path traversal

Because actorID is interpolated directly into filename and then passed to filepath.Join, any actor ID containing path separators (e.g., ../ or ..\) will be treated as path components and can escape the note directory after path cleaning. This allows a remote sync client identifier to place conflict copies outside the intended directory (or overwrite unrelated files) instead of always writing alongside the original. Consider normalizing actorID to a safe character set (e.g., replacing path separators with _) before building the filename.

Useful? React with 👍 / 👎.

}
29 changes: 29 additions & 0 deletions sync_conflicts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package main

import (
"path/filepath"
"testing"
"time"
)

func TestConflictCopyPath(t *testing.T) {
original := filepath.Join("notes", "entry.txt")
at := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC)

got := conflictCopyPath(original, at, "device-01")
expected := filepath.Join("notes", "entry.txt.conflict-20240506T070809Z-device-01")
if got != expected {
t.Fatalf("expected %q, got %q", expected, got)
}
}

func TestConflictCopyPathDefaultsActor(t *testing.T) {
original := filepath.Join("notes", "entry.txt")
at := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC)

got := conflictCopyPath(original, at, "")
expected := filepath.Join("notes", "entry.txt.conflict-20240506T070809Z-unknown")
if got != expected {
t.Fatalf("expected %q, got %q", expected, got)
}
}
140 changes: 140 additions & 0 deletions watcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package main

import (
"context"
"io/fs"
"os"
"path/filepath"
"sort"
"time"
)

type EventType string

const (
EventCreate EventType = "create"
EventModify EventType = "modify"
EventDelete EventType = "delete"
)

type FileEvent struct {
Path string
Type EventType
}

type fileState struct {
modTime time.Time
size int64
}

type Watcher struct {
dir string
state map[string]fileState
}

func NewWatcher(dir string) (*Watcher, error) {
state, err := snapshotDir(dir)
if err != nil {
return nil, err
}

return &Watcher{dir: dir, state: state}, nil
}

func (w *Watcher) Poll() ([]FileEvent, error) {
current, err := snapshotDir(w.dir)
if err != nil {
return nil, err
}

var events []FileEvent
for path, cur := range current {
prev, ok := w.state[path]
if !ok {
events = append(events, FileEvent{Path: path, Type: EventCreate})
continue
}
if cur.modTime != prev.modTime || cur.size != prev.size {
events = append(events, FileEvent{Path: path, Type: EventModify})
}
}

for path := range w.state {
if _, ok := current[path]; !ok {
events = append(events, FileEvent{Path: path, Type: EventDelete})
}
}

sort.Slice(events, func(i, j int) bool {
if events[i].Path == events[j].Path {
return events[i].Type < events[j].Type
}
return events[i].Path < events[j].Path
})

w.state = current
return events, nil
}

func (w *Watcher) Start(ctx context.Context, interval time.Duration) (<-chan FileEvent, <-chan error) {
events := make(chan FileEvent)
errs := make(chan error, 1)

go func() {
defer close(events)
defer close(errs)

ticker := time.NewTicker(interval)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return
case <-ticker.C:
batch, err := w.Poll()
if err != nil {
errs <- err
return
}
for _, event := range batch {
select {
case <-ctx.Done():
return
case events <- event:
}
}
}
}
}()

return events, errs
}

func snapshotDir(dir string) (map[string]fileState, error) {
state := make(map[string]fileState)
err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
if !entry.Type().IsRegular() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
state[path] = fileState{modTime: info.ModTime(), size: info.Size()}
return nil
})
if err != nil {
if os.IsNotExist(err) {
return state, nil
}
return nil, err
}
return state, nil
}
65 changes: 65 additions & 0 deletions watcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package main

import (
"os"
"path/filepath"
"testing"
"time"
)

func TestWatcherDetectsCreateModifyDelete(t *testing.T) {
dir := t.TempDir()
watcher, err := NewWatcher(dir)
if err != nil {
t.Fatalf("NewWatcher returned error: %v", err)
}

events, err := watcher.Poll()
if err != nil {
t.Fatalf("Poll returned error: %v", err)
}
if len(events) != 0 {
t.Fatalf("expected no events, got %v", events)
}

notePath := filepath.Join(dir, "note.txt")
if err := os.WriteFile(notePath, []byte("first"), 0o600); err != nil {
t.Fatalf("write failed: %v", err)
}

events, err = watcher.Poll()
if err != nil {
t.Fatalf("Poll returned error after create: %v", err)
}
if len(events) != 1 || events[0].Type != EventCreate || events[0].Path != notePath {
t.Fatalf("expected create event for %s, got %v", notePath, events)
}

if err := os.WriteFile(notePath, []byte("second"), 0o600); err != nil {
t.Fatalf("rewrite failed: %v", err)
}
modTime := time.Now().Add(2 * time.Second)
if err := os.Chtimes(notePath, modTime, modTime); err != nil {
t.Fatalf("chtimes failed: %v", err)
}

events, err = watcher.Poll()
if err != nil {
t.Fatalf("Poll returned error after modify: %v", err)
}
if len(events) != 1 || events[0].Type != EventModify || events[0].Path != notePath {
t.Fatalf("expected modify event for %s, got %v", notePath, events)
}

if err := os.Remove(notePath); err != nil {
t.Fatalf("remove failed: %v", err)
}

events, err = watcher.Poll()
if err != nil {
t.Fatalf("Poll returned error after delete: %v", err)
}
if len(events) != 1 || events[0].Type != EventDelete || events[0].Path != notePath {
t.Fatalf("expected delete event for %s, got %v", notePath, events)
}
}