P2-5: Real OS filesystem — vfs.FS with per-platform FileID #25
Labels
No labels
data-integrity
engine
platform
procurement
remote
scaffold
ui
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Cordy/cairn-desktop#25
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Depends on P2-4. Can be done in parallel with the
remotetasks.Goal
Implement
vfs.FSagainst the real filesystem, satisfying exactly the contractMemFSestablished in Task 5 — so the engine cannot tell them apart.Files
internal/vfs/osfs.go,internal/vfs/fileid_unix.go,internal/vfs/fileid_windows.go,internal/vfs/osfs_test.goNote: this package may use
os,path/filepathandsyscall. Onlyinternal/syncis forbidden from doing so.FileID — the whole reason for the build tags
Rename detection (Task 11) depends on an identifier that survives a rename.
//go:build !windows): the inode fromsyscall.Stat_t.Ino, plus the device number to be safe across mounts. Format as"dev:ino".//go:build windows): the file index fromGetFileInformationByHandle—nFileIndexHigh/nFileIndexLow, combined with the volume serial number."". Empty means unknown, and the engine already treats unknown as "never matches". Silently substituting something else would cause false rename detection, which moves the wrong file.Path handling
/-separated, NFC, root-relative paths.osfsowns translation to and from native paths —filepath.FromSlashoutward,filepath.ToSlashplussync.Normaliseinward.\\?\for absolute paths to escape the 260-characterMAX_PATHlimit.Atomic writes
Writemust not leave a truncated file if the process dies mid-write. Write to a temporary file in the same directory, thenos.Renameover the target — rename within a filesystem is atomic. A half-written file that the engine then hashes and records as "synced" is silent corruption.Steps
t.TempDir():MemFScontract from Task 5, re-run againstosfs(extract those assertions into a shared test helper so both implementations are verified against the same contract)FileIDis non-empty on the test platform and survives a renameWriteis atomic: no partial file is observable, and an interrupted write leaves the original intactFileIDhelpers.var _ FS = (*OSFS)(nil)git commit -s -m "feat(vfs): real filesystem with per-platform FileID"Acceptance criteria
MemFSandOSFS.FileIDsurvives renames on the platform under test, and is""rather than wrong when unavailable.Amendment — 2026-09-11: phase-1 hand-off (binding rulings Task 3 F1 and F5; final review X1, X8, X13)
These points amend "Path handling" above (
filepath.ToSlashplussync.Normaliseinward).Task 3 F1 — names that cannot be normalised safely
\is never passed toNormalise.Normalisewould turn it into a different path. Skip the name and report it perfile with a legible reason.
Walkchecks that no two distinct native names normalise to the same enginepath. This covers backslash twins, NFC/NFD twins on ext4, and NFC compatibility singletons.
Skip every member of such a group with a reason, as #14 does for case collisions.
Leaving it out of
Walkwhile it has a state row reads as a local delete, and the engine wouldthen delete the server copy. Since commit
e7cef2d(
fix(sync): refuse non-canonical paths and their canonical twins), the engine refuses alisted path that is not canonical, together with its canonical twin. Listing the raw name therefore
meets (c). Omitting it does not.
Task 3 F5 — keep the native name
Keep the native name observed for every entry, using a per-walk engine-path → native-name map or
the equivalent. Use it for
Open,Write,RemoveandMoveon an existing entry.filepath.FromSlash(enginePath)is only for names the client itself creates. On ext4, anNFC-rebuilt path for an NFD-stored name returns
ErrNotExist, which under R2 reads as a normalrace one step from a deletion.
Final review X1 — root containment (defence in depth)
The engine now refuses paths with an empty,
.or..segment, or a leading/, as Skips. Itdoes this on every platform (commit
6a9a06b,fix(sync): refuse paths that could resolve outside the sync root). OSFS must not rely on that alone:filepath.IsLocalon the relative path, and refuse volume names such asC:and UNCforms on Windows.
os.Root(Go ≥ 1.24, so available under thego 1.25directive). A symlinked parent directory then cannot carry a write out of the sync folder
either.
Final review X13 — report the on-disk casing on a case-insensitive volume
The problem: the server has
Docs/a.txt, and it is downloaded into an existing localdocs/.The engine records
Docs/a.txt, whileWalklater returnsdocs/a.txt. The case-collisioncheck then freezes both paths on every pass, so the subtree never syncs again. No bytes are lost.
The requirement:
WalkandStatreport the name as stored on disk. AWritewhose existingancestor differs from the engine path only in case must not silently land under the other
casing. Either refuse it with a distinct error, which becomes a Skip, or report the path actually
written, so the engine never records a path that
Walkwill not return. Related deferredfindings: Task 14 F3 (case-only renames) and F4 (folder twins).
Final review X8 — conflict names within NAME_MAX
ConflictNameadds 37 bytes ((conflicted copy YYYY-MM-DD HH-MM-SS)), plusNunder R4. Alegal 244-byte name therefore becomes 282 bytes, beyond the 255-byte limit of ext4 and APFS and
the 255 UTF-16 units of NTFS. The conflict copy then fails on every pass. That is a permanent
Skip; both versions stay where they are.
The first task that writes conflict copies to a real disk (this one) truncates the conflict
name's stem, on a UTF-8 character boundary, so the whole segment fits. It keeps the extension,
#10's format and the R4 numbering, and adds a test with a 244-byte name. The change itself is in
internal/sync/conflict.go/conflictPath; the limit is a property of this filesystem.Done
What was built
OSFS: real-filesystemvfs.FSwith per-platformFileID(dev:inoUnix,vol:indexWindows), atomicWrite(temp file + fsync + rename), root containment viaos.Root+inside()/IsLocal.MemFSandOSFS.Unsynced(not omitted); the engine Skips them and their subtree instead of reading them as deletes — closes the review's F1 data-integrity gap.Tests
CI run #23, linux/arm64, green. Coverage
total: 90.5%. 19 contract cases × 2 FS impls, plus OSFS-specific tests (atomic write, FileID survival, symlink/pipe skip, non-ASCII round trip, on-disk casing, root-escape refusal, twin grouping), 19 mutation kills across both rounds, cross-targetgo vetclean for windows/amd64 and linux/arm64.Acceptance criteria
TestMemFS/TestOSFS.TestOSFSWriteIsAtomic; mutants M1/M11 killed.""when unavailable —TestOSFSFileIDSurvivesRenames/...IsEmptyWhenUnknown.TestOSFSNonASCIIRoundTrip.enginePaths,names/nativeOf,inside+os.Root,ErrDifferentSpelling,conflictName/truncateUTF8); see review for full mapping.Rulings
-s+ Co-Authored-By.os.Rootpaths untested here; vet stays clean for windows/amd64 & linux/arm64; recorded below.Unsynced stringtoFileInfo; Walk lists non-file/folder entries as Unsynced; engine Skips path+subtree before Decide — CLAUDE.md "never delete on ambiguity" and amendment F1(c) outrank the issue's plain "skip" wording.t.Fatalfs (not Skip) on Linux, so the green CI run is real evidence it ran.Deferred
nameOnDisk(osfs.go:451); F3Writedrops replaced file's permission bits (osfs.go:542); F4deleteLocalhas no re-check beforeRemove, carried to #27; F5 interrupted-write temp files never reclaimed (osfs.go:140).Writeover a pipe still uses errLink's symlink-wording text; Windows junctions untested until #19; a new server file under a linked folder isn't downloaded while the link stands (ruling accepts as noise, not a delete).Implemented and reviewed by Claude (subagent-driven), landed on main after review and green CI.