Task 14: Case-collision detection #14

Closed
opened 2026-09-10 17:17:18 +00:00 by Cordy · 1 comment
Owner

Depends on Task 13.

Goal

Detect paths that differ only in letter case and refuse to sync them, rather than letting one silently overwrite the other.

Why this is data loss, not a nuisance

APFS and NTFS are case-insensitive by default; ext4 is case-sensitive. A Cairn server on Linux can legitimately hold Report.pdf and report.pdf. A Mac or Windows client cannot represent both. Downloading them naively means the second overwrites the first — data loss with no error message.

Files

  • Create: internal/sync/casefold.go, internal/sync/casefold_test.go
  • Modify: internal/sync/engine.go

Produces

func DetectCaseCollisions(paths []string) map[string][]string

Keyed by the lowercased path, valued with the two-or-more real paths that fold onto it. Non-colliding paths are absent. The same path listed twice is not a collision.

Steps

  • Write the failing tests:
func TestDetectCaseCollisions(t *testing.T) {
	got := DetectCaseCollisions([]string{
		"Report.pdf", "report.pdf", "unique.txt", "a/B.txt", "a/b.txt",
	})
	if len(got) != 2 {
		t.Fatalf("expected 2 collision groups, got %d: %v", len(got), got)
	}
	group, ok := got["report.pdf"]
	if !ok || len(group) != 2 {
		t.Errorf("expected a 2-member group for report.pdf, got %v", group)
	}
}

func TestNoCollisionsWhenAllDistinct(t *testing.T) {
	if got := DetectCaseCollisions([]string{"a.txt", "b.txt"}); len(got) != 0 {
		t.Errorf("expected no collisions, got %v", got)
	}
}

func TestCaseCollisionIsNotFlaggedForIdenticalPaths(t *testing.T) {
	if got := DetectCaseCollisions([]string{"a.txt", "a.txt"}); len(got) != 0 {
		t.Errorf("identical paths must not count as a collision, got %v", got)
	}
}

// The engine must refuse to sync colliding paths rather than clobber one.
func TestSyncSkipsCaseCollisions(t *testing.T) {
	e, _, rem := newEngine(t)
	rem.Put("Report.pdf", strings.NewReader("upper"), time.Now())
	rem.Put("report.pdf", strings.NewReader("lower"), time.Now())

	res, err := e.SyncOnce()
	if err != nil {
		t.Fatalf("a collision must not fail the whole sync: %v", err)
	}
	if len(res.Skipped) != 2 {
		t.Fatalf("expected both colliding paths skipped, got %+v", res.Skipped)
	}
	for _, s := range res.Skipped {
		if !strings.Contains(s.Reason, "case") {
			t.Errorf("skip reason should explain the case collision, got %q", s.Reason)
		}
	}
}
  • Run them, confirm they fail.
  • Implement DetectCaseCollisions. Group by strings.ToLower, using a set per group so duplicates of the same exact path collapse.
  • Wire it into SyncOnce. After building the union of paths and before the per-path loop, compute the collided set once. Inside the loop, before the IllegalOn check, skip any collided path with a reason explaining that another path differs only in letter case and cannot coexist on a case-insensitive filesystem.
  • Run the full package. TestConvergence must still pass — it uses distinct lowercase names, so it introduces no collisions.
  • Commit: git commit -s -m "feat(sync): detect and quarantine case-only path collisions"

Acceptance criteria

  • Colliding paths are skipped, never synced.
  • The skip reason is legible to a non-technical user.
  • No error is returned — one collision must not stop the folder.
Depends on Task 13. ## Goal Detect paths that differ only in letter case and refuse to sync them, rather than letting one silently overwrite the other. ## Why this is data loss, not a nuisance APFS and NTFS are case-insensitive by default; ext4 is case-sensitive. A Cairn server on Linux can legitimately hold `Report.pdf` **and** `report.pdf`. A Mac or Windows client cannot represent both. Downloading them naively means the second overwrites the first — **data loss with no error message**. ## Files - Create: `internal/sync/casefold.go`, `internal/sync/casefold_test.go` - Modify: `internal/sync/engine.go` ## Produces ```go func DetectCaseCollisions(paths []string) map[string][]string ``` Keyed by the lowercased path, valued with the two-or-more real paths that fold onto it. Non-colliding paths are absent. The same path listed twice is **not** a collision. ## Steps - [ ] **Write the failing tests:** ```go func TestDetectCaseCollisions(t *testing.T) { got := DetectCaseCollisions([]string{ "Report.pdf", "report.pdf", "unique.txt", "a/B.txt", "a/b.txt", }) if len(got) != 2 { t.Fatalf("expected 2 collision groups, got %d: %v", len(got), got) } group, ok := got["report.pdf"] if !ok || len(group) != 2 { t.Errorf("expected a 2-member group for report.pdf, got %v", group) } } func TestNoCollisionsWhenAllDistinct(t *testing.T) { if got := DetectCaseCollisions([]string{"a.txt", "b.txt"}); len(got) != 0 { t.Errorf("expected no collisions, got %v", got) } } func TestCaseCollisionIsNotFlaggedForIdenticalPaths(t *testing.T) { if got := DetectCaseCollisions([]string{"a.txt", "a.txt"}); len(got) != 0 { t.Errorf("identical paths must not count as a collision, got %v", got) } } // The engine must refuse to sync colliding paths rather than clobber one. func TestSyncSkipsCaseCollisions(t *testing.T) { e, _, rem := newEngine(t) rem.Put("Report.pdf", strings.NewReader("upper"), time.Now()) rem.Put("report.pdf", strings.NewReader("lower"), time.Now()) res, err := e.SyncOnce() if err != nil { t.Fatalf("a collision must not fail the whole sync: %v", err) } if len(res.Skipped) != 2 { t.Fatalf("expected both colliding paths skipped, got %+v", res.Skipped) } for _, s := range res.Skipped { if !strings.Contains(s.Reason, "case") { t.Errorf("skip reason should explain the case collision, got %q", s.Reason) } } } ``` - [ ] **Run them, confirm they fail.** - [ ] **Implement `DetectCaseCollisions`.** Group by `strings.ToLower`, using a set per group so duplicates of the same exact path collapse. - [ ] **Wire it into `SyncOnce`.** After building the union of paths and before the per-path loop, compute the collided set once. Inside the loop, before the `IllegalOn` check, skip any collided path with a reason explaining that another path differs only in letter case and cannot coexist on a case-insensitive filesystem. - [ ] **Run the full package.** `TestConvergence` must still pass — it uses distinct lowercase names, so it introduces no collisions. - [ ] **Commit:** `git commit -s -m "feat(sync): detect and quarantine case-only path collisions"` ## Acceptance criteria - Colliding paths are skipped, never synced. - The skip reason is legible to a non-technical user. - No error is returned — one collision must not stop the folder.
Cordy added this to the phase-1-engine milestone 2026-09-10 17:17:18 +00:00
Author
Owner

Done

  • 778708291c — feat(sync): detect and quarantine case-only path collisions
  • 464cabcafe — fix(sync): fold case collisions the way APFS and NTFS compare names

What was built

  • DetectCaseCollisions(paths []string) map[string][]string groups paths that fold onto the same case-insensitive key, keyed by the folded path, 2+ real paths per group.
  • Wired into SyncOnce: the collided set is computed once after the path union and before the loop; each collided path is skipped, before IllegalOn/observe/Decide/any FS,Remote,State call, so it can never be downloaded, uploaded, deleted, or counted as missing.
  • The skip reason names the twin(s) and explains a case-insensitive filesystem (default on macOS/Windows) can't hold them side by side; tells the user to rename one.
  • Fix round 1 (F1): the key now uses strings.ToLower(strings.ToUpper(cases.Fold().String(p))) (golang.org/x/text/cases), so Greek final-sigma, Turkish dotless-ı, long-s and ß/ss twins are caught too, not just ASCII — plain ToLower alone missed those and left the exact silent-overwrite bug open.

Tests
CI run #14 (http://192.168.10.245/Cordy/cairn-desktop/actions/runs/14), Go 1.25.5, linux/arm64 — go vet ./... and go test -count=1 ./... green across internal/remote, internal/state, internal/sync, internal/vfs. Locally: TDD RED/GREEN cycles for TestDetectCaseCollisions*, TestSyncSkipsCaseCollisions, TestSyncNeverTouchesCaseCollisions, TestDetectCaseCollisionsFoldsBeyondASCII, plus TestConvergence (50 seeds) all pass; gofmt clean; go mod tidy byte-identical.

Acceptance criteria

  • Colliding paths are skipped, never synced — skip precedes every FS/Remote/State call; TestSyncSkipsCaseCollisions and TestSyncNeverTouchesCaseCollisions confirm no op, no download, no state write.
  • The skip reason is legible to a non-technical user — names the twin(s), explains why, says what to do (rename one).
  • No error returned; one collision must not stop the folder — each collision is a continue; both engine tests assert err == nil.

Rulings

  • R12: commit subjects use the issue's exact text, always git commit -s, carrying Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> — DCO non-negotiable, attribution trailer per session policy.
  • F1: fold via golang.org/x/text/cases instead of plain strings.ToLower, since spec §4 says "Detect and quarantine rather than clobber" and plain ToLower left non-ASCII twins unguarded (observed on this Mac's APFS volume).
  • CV1: CI on Go 1.25.5/linux is the controller's own post-push gate (R11), not a code gap — satisfied by this run.
  • CV2: RED/GREEN reproduced independently in a scratch clone of 7787082 (build failure, then behavioural failures, then all green) — satisfied.
  • CV3: APFS observed directly (full Unicode fold); NTFS ı/I rests on the $UpCase table and is covered by the F1 key regardless, cost if wrong is a needless skip only.

Deferred

  • F2 (minor): "no error returned" tests the folder-continues behaviour only indirectly; no committed test shows a non-colliding path syncing in the same pass as a collision.
  • F3 (minor): a case-only rename (e.g. report.pdfReport.pdf on macOS) is quarantined every pass rather than propagated as a rename.
  • F4 (minor): folder-level case twins (A vs a) are flagged but their children still sync individually, so the skip message and behaviour don't fully match.

Implemented and reviewed by Claude (subagent-driven), landed on main after review and green CI.

**Done** - http://192.168.10.245/Cordy/cairn-desktop/commit/778708291c24b8280818a0b6ab551a1e3d9196f7 — feat(sync): detect and quarantine case-only path collisions - http://192.168.10.245/Cordy/cairn-desktop/commit/464cabcafe782a2d2a5b5d5fb30d437cfe7c79c5 — fix(sync): fold case collisions the way APFS and NTFS compare names **What was built** - `DetectCaseCollisions(paths []string) map[string][]string` groups paths that fold onto the same case-insensitive key, keyed by the folded path, 2+ real paths per group. - Wired into `SyncOnce`: the collided set is computed once after the path union and before the loop; each collided path is skipped, before `IllegalOn`/`observe`/`Decide`/any FS,Remote,State call, so it can never be downloaded, uploaded, deleted, or counted as missing. - The skip reason names the twin(s) and explains a case-insensitive filesystem (default on macOS/Windows) can't hold them side by side; tells the user to rename one. - Fix round 1 (F1): the key now uses `strings.ToLower(strings.ToUpper(cases.Fold().String(p)))` (golang.org/x/text/cases), so Greek final-sigma, Turkish dotless-ı, long-s and ß/ss twins are caught too, not just ASCII — plain `ToLower` alone missed those and left the exact silent-overwrite bug open. **Tests** CI run #14 (http://192.168.10.245/Cordy/cairn-desktop/actions/runs/14), Go 1.25.5, linux/arm64 — `go vet ./...` and `go test -count=1 ./...` green across `internal/remote`, `internal/state`, `internal/sync`, `internal/vfs`. Locally: TDD RED/GREEN cycles for `TestDetectCaseCollisions*`, `TestSyncSkipsCaseCollisions`, `TestSyncNeverTouchesCaseCollisions`, `TestDetectCaseCollisionsFoldsBeyondASCII`, plus `TestConvergence` (50 seeds) all pass; `gofmt` clean; `go mod tidy` byte-identical. **Acceptance criteria** - Colliding paths are skipped, never synced — skip precedes every FS/Remote/State call; `TestSyncSkipsCaseCollisions` and `TestSyncNeverTouchesCaseCollisions` confirm no op, no download, no state write. - The skip reason is legible to a non-technical user — names the twin(s), explains why, says what to do (rename one). - No error returned; one collision must not stop the folder — each collision is a `continue`; both engine tests assert `err == nil`. **Rulings** - R12: commit subjects use the issue's exact text, always `git commit -s`, carrying `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>` — DCO non-negotiable, attribution trailer per session policy. - F1: fold via `golang.org/x/text/cases` instead of plain `strings.ToLower`, since spec §4 says "Detect and quarantine rather than clobber" and plain ToLower left non-ASCII twins unguarded (observed on this Mac's APFS volume). - CV1: CI on Go 1.25.5/linux is the controller's own post-push gate (R11), not a code gap — satisfied by this run. - CV2: RED/GREEN reproduced independently in a scratch clone of 7787082 (build failure, then behavioural failures, then all green) — satisfied. - CV3: APFS observed directly (full Unicode fold); NTFS ı/I rests on the $UpCase table and is covered by the F1 key regardless, cost if wrong is a needless skip only. **Deferred** - F2 (minor): "no error returned" tests the folder-continues behaviour only indirectly; no committed test shows a non-colliding path syncing in the same pass as a collision. - F3 (minor): a case-only rename (e.g. `report.pdf` → `Report.pdf` on macOS) is quarantined every pass rather than propagated as a rename. - F4 (minor): folder-level case twins (`A` vs `a`) are flagged but their children still sync individually, so the skip message and behaviour don't fully match. _Implemented and reviewed by Claude (subagent-driven), landed on main after review and green CI._
Cordy closed this issue 2026-09-11 00:35:44 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/cairn-desktop#14
No description provided.