Task 6: Remote interface and in-memory implementation #6

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

Depends on Task 5.

Goal

Abstract the Cairn server so the engine can be tested without a network. The real WebDAV + tus implementation arrives in Plan 2.

Files

  • Create: internal/remote/remote.go, internal/remote/memremote.go, internal/remote/memremote_test.go

Produces

type Entry struct {
	Path    string
	Size    int64
	ModTime time.Time
	IsDir   bool
	ETag    string // change token: MUST differ whenever content differs
}

type Remote interface {
	List() ([]Entry, error)
	Get(path string) (io.ReadCloser, error)
	Put(path string, r io.Reader, mod time.Time) (Entry, error)
	Mkcol(path string) error
	Delete(path string) error
	Move(from, to string) error
	Stat(path string) (Entry, bool, error)
}

func NewMemRemote() *MemRemote

The method names map directly onto WebDAV verbs: List→PROPFIND, Get→GET, Put→PUT/tus, Mkcol→MKCOL, Delete→DELETE, Move→MOVE. Keeping that shape now means Plan 2's real implementation is a thin translation.

The ETag contract — this is load-bearing

Remote change detection compares ETags. The ETag must change whenever content changes, or the engine will miss remote edits and silently diverge. In MemRemote, implement it as a hash of the content, which satisfies the contract exactly.

Steps

  • Write the failing tests:
    • Put returns a non-empty ETag and the correct size
    • putting different content to the same path yields a different ETag
    • putting identical content yields an identical ETag
    • Move relocates the object and leaves nothing behind
    • Put creates parent collections implicitly
func TestMemRemoteETagChangesWithContent(t *testing.T) {
	r := NewMemRemote()
	first, _ := r.Put("a.txt", strings.NewReader("one"), time.Now())
	second, _ := r.Put("a.txt", strings.NewReader("two"), time.Now())
	if first.ETag == second.ETag {
		t.Fatal("ETag must change when content changes — remote change detection depends on it")
	}
}
  • Run them, confirm they fail.
  • Implement MemRemote. Map plus mutex; ETag = truncated SHA-256 of content; Get returns a copy.
  • Assert: var _ Remote = (*MemRemote)(nil)
  • Run tests, confirm they pass.
  • Commit: git commit -s -m "feat(remote): Remote interface and in-memory implementation"

Acceptance criteria

  • All five behaviours above have a named test.
  • List returns sorted, canonical paths.
Depends on Task 5. ## Goal Abstract the Cairn server so the engine can be tested without a network. The real WebDAV + tus implementation arrives in Plan 2. ## Files - Create: `internal/remote/remote.go`, `internal/remote/memremote.go`, `internal/remote/memremote_test.go` ## Produces ```go type Entry struct { Path string Size int64 ModTime time.Time IsDir bool ETag string // change token: MUST differ whenever content differs } type Remote interface { List() ([]Entry, error) Get(path string) (io.ReadCloser, error) Put(path string, r io.Reader, mod time.Time) (Entry, error) Mkcol(path string) error Delete(path string) error Move(from, to string) error Stat(path string) (Entry, bool, error) } func NewMemRemote() *MemRemote ``` The method names map directly onto WebDAV verbs: `List`→PROPFIND, `Get`→GET, `Put`→PUT/tus, `Mkcol`→MKCOL, `Delete`→DELETE, `Move`→MOVE. Keeping that shape now means Plan 2's real implementation is a thin translation. ## The ETag contract — this is load-bearing Remote change detection compares ETags. **The ETag must change whenever content changes**, or the engine will miss remote edits and silently diverge. In `MemRemote`, implement it as a hash of the content, which satisfies the contract exactly. ## Steps - [ ] **Write the failing tests:** - `Put` returns a non-empty ETag and the correct size - putting *different* content to the same path yields a *different* ETag - putting *identical* content yields an *identical* ETag - `Move` relocates the object and leaves nothing behind - `Put` creates parent collections implicitly ```go func TestMemRemoteETagChangesWithContent(t *testing.T) { r := NewMemRemote() first, _ := r.Put("a.txt", strings.NewReader("one"), time.Now()) second, _ := r.Put("a.txt", strings.NewReader("two"), time.Now()) if first.ETag == second.ETag { t.Fatal("ETag must change when content changes — remote change detection depends on it") } } ``` - [ ] **Run them, confirm they fail.** - [ ] **Implement `MemRemote`.** Map plus mutex; ETag = truncated SHA-256 of content; `Get` returns a copy. - [ ] **Assert:** `var _ Remote = (*MemRemote)(nil)` - [ ] **Run tests, confirm they pass.** - [ ] **Commit:** `git commit -s -m "feat(remote): Remote interface and in-memory implementation"` ## Acceptance criteria - All five behaviours above have a named test. - `List` returns sorted, canonical paths.
Cordy added this to the phase-1-engine milestone 2026-09-10 17:15:27 +00:00
Author
Owner

Done

What was built

  • internal/remote/remote.go: Entry struct and the Remote interface (List, Get, Put, Mkcol, Delete, Move, Stat) matching the issue's Produces block, with doc comments for the ETag and fs.ErrNotExist contracts.
  • internal/remote/memremote.go: MemRemote, an in-memory mutex-guarded implementation; ETag = first 16 hex chars of SHA-256(content); Get returns a private copy; Put creates parent collections implicitly.
  • Fix round 1: Move now rejects moving into its own subtree (fs.ErrInvalid) and moving onto an existing file/collection where either side is a collection (fs.ErrExist), matching MemFS.Move's existing guards instead of silently dropping bytes or merging trees.
  • internal/remote/memremote_test.go: 15 tests total (13 original + 2 added in the fix round).

Tests

  • go test -v ./internal/remote/... — all 15 tests pass, including the issue's five named behaviours and the two new Move guard tests, each shown red-first against the pre-fix code.
  • go vet ./... and go test -count=1 ./... clean across internal/remote, internal/sync, internal/vfs; gofmt -l . empty.
  • CI run #6 on linux/arm64 (Go 1.25.5): green — go vet ./... and go test -count=1 ./... both pass (ok cairn.ch/desktop/internal/remote, internal/sync, internal/vfs). No coverage step in this workflow.

Acceptance criteria

  • All five behaviours have a named test: Put ETag/size (TestMemRemotePutReturnsETagAndSize), different content → different ETag (TestMemRemoteETagChangesWithContent, issue's code verbatim), identical content → identical ETag (TestMemRemoteETagStableForIdenticalContent), Move relocates and leaves nothing behind (TestMemRemoteMoveRelocatesAndLeavesNothingBehind + TestMemRemoteMoveDirectoryMovesChildren), Put creates parent collections implicitly (TestMemRemotePutCreatesParentCollectionsImplicitly).
  • List returns sorted, canonical paths — TestMemRemoteListSorted.

Rulings

  • R2: MemFS and MemRemote return an error wrapping io/fs.ErrNotExist (check with errors.Is) for Get/Delete/Move on a missing path — implemented and tested (TestMemRemoteMissingPathErrorsWrapErrNotExist).
  • R12: commit subjects use the exact issue message, git commit -s, plus the Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> trailer — both commits comply.

Deferred

  • F2: the Remote.Put doc's "identical content ⇒ identical ETag" guarantee is one-directional in the issue's actual contract; should be scoped to MemRemote only.
  • F3: several error branches (Get/Put on a collection, Mkcol onto a file, a file parent in mkcolParentsLocked, Move to same path) are untested and don't wrap a sentinel error.
  • F4: Delete/Move/parent-creation/notExist are near-verbatim copies of MemFS, already drifted once (F1).
  • F5: non-canonical paths (leading /, trailing /) are stored verbatim, so List can return non-canonical output for non-canonical input.

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

**Done** - [feat(remote): Remote interface and in-memory implementation](http://192.168.10.245/Cordy/cairn-desktop/commit/c7357d3290db25a3e1a365cb2f3ee6f794b47d56) - [fix(remote): MemRemote.Move rejects unsafe destinations](http://192.168.10.245/Cordy/cairn-desktop/commit/dd3cf88e0995c3717e3c218e27850c000e923d13) **What was built** - `internal/remote/remote.go`: `Entry` struct and the `Remote` interface (`List`, `Get`, `Put`, `Mkcol`, `Delete`, `Move`, `Stat`) matching the issue's `Produces` block, with doc comments for the ETag and `fs.ErrNotExist` contracts. - `internal/remote/memremote.go`: `MemRemote`, an in-memory mutex-guarded implementation; ETag = first 16 hex chars of SHA-256(content); `Get` returns a private copy; `Put` creates parent collections implicitly. - Fix round 1: `Move` now rejects moving into its own subtree (`fs.ErrInvalid`) and moving onto an existing file/collection where either side is a collection (`fs.ErrExist`), matching `MemFS.Move`'s existing guards instead of silently dropping bytes or merging trees. - `internal/remote/memremote_test.go`: 15 tests total (13 original + 2 added in the fix round). **Tests** - `go test -v ./internal/remote/...` — all 15 tests pass, including the issue's five named behaviours and the two new `Move` guard tests, each shown red-first against the pre-fix code. - `go vet ./...` and `go test -count=1 ./...` clean across `internal/remote`, `internal/sync`, `internal/vfs`; `gofmt -l .` empty. - CI run [#6](http://192.168.10.245/Cordy/cairn-desktop/actions/runs/6) on linux/arm64 (Go 1.25.5): green — `go vet ./...` and `go test -count=1 ./...` both pass (`ok cairn.ch/desktop/internal/remote`, `internal/sync`, `internal/vfs`). No coverage step in this workflow. **Acceptance criteria** - All five behaviours have a named test: Put ETag/size (`TestMemRemotePutReturnsETagAndSize`), different content → different ETag (`TestMemRemoteETagChangesWithContent`, issue's code verbatim), identical content → identical ETag (`TestMemRemoteETagStableForIdenticalContent`), Move relocates and leaves nothing behind (`TestMemRemoteMoveRelocatesAndLeavesNothingBehind` + `TestMemRemoteMoveDirectoryMovesChildren`), Put creates parent collections implicitly (`TestMemRemotePutCreatesParentCollectionsImplicitly`). - `List` returns sorted, canonical paths — `TestMemRemoteListSorted`. **Rulings** - R2: MemFS and MemRemote return an error wrapping `io/fs.ErrNotExist` (check with errors.Is) for Get/Delete/Move on a missing path — implemented and tested (`TestMemRemoteMissingPathErrorsWrapErrNotExist`). - R12: commit subjects use the exact issue message, `git commit -s`, plus the `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>` trailer — both commits comply. **Deferred** - F2: the `Remote.Put` doc's "identical content ⇒ identical ETag" guarantee is one-directional in the issue's actual contract; should be scoped to `MemRemote` only. - F3: several error branches (Get/Put on a collection, Mkcol onto a file, a file parent in `mkcolParentsLocked`, Move to same path) are untested and don't wrap a sentinel error. - F4: `Delete`/`Move`/parent-creation/`notExist` are near-verbatim copies of `MemFS`, already drifted once (F1). - F5: non-canonical paths (leading `/`, trailing `/`) are stored verbatim, so `List` can return non-canonical output for non-canonical input. _Implemented and reviewed by Claude (subagent-driven), landed on main after review and green CI._
Cordy closed this issue 2026-09-10 21:20:25 +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#6
No description provided.