Repository navigation
feat(sync): symlinks sync as links, shown in the tree, never followed - #273
Merged
Merged
Conversation
A symlink in a mount used to be dropped at the door: walkFolder skipped anything that was not a regular file, so the link was never journaled, absent on every peer, and silently so. A symlink is now an op like any other, carrying its target string (Op.Link) and NO blob: the scan uses Readlink and never opens what the link points at, so a link to ~/.ssh/id_rsa ships eleven characters and not one byte of the key. Materialize recreates the link entry on peers verbatim and never resolves it; a link pointing outside the mount is an ordinary op that only the user's own filesystem ever follows. The hub lists a link as a link — name, target, no size — in both the folder viewer (DirSource, via Readlink) and hub mode (an Op.Link folded into FileInfo.Link). The viewer shows the target, with a click-through when it lands on a file inside the project, and never fetches content for it. Content is refused at the one door every reader goes through: Source.Open on both sources answers errLinkHasNoContent for a link entry, and DirSource Lstat-checks the disk as well so a link placed after the last listing is not followed either. DirSource.Open used to call os.Open, which follows the link — the existing security test caught it serving the outside file through the viewer, so that test now pins the new contract (listed as a link, served on no content route), and a hub-mode twin covers a synced link op. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
What changed
journal.Op.Link/FileState.Linkcarry the target; Replay folds it.store.CachedFile.Linklets change detection notice a retarget.walkFolderanswers a newvSymlinkverdict, so scan, Explain, Measure and Drift all see links the same way. Scan usesReadlink, neverOpen.materializeSymlinkrecreates the link entry on peers atomically and never resolves it; an untracked real file at that path is never clobbered.DirSourcelists links viaReadlink;RemoteSourcefoldsOp.LinkintoFileInfo.Link; the tree node carrieslink.Source.Openimplementations refuse a link entry (errLinkHasNoContent).DirSourcealsoLstat-checks the disk so a link placed after the last listing is not followed. Every content door (viewer, download, render, shares, MCP, co-editing) goes throughOpen, so the refusal lives once.SymlinkViewthat shows the target and never fetches content.Tests
syncer/symlink_test.go: a link syncs as a link between two devices; a link outside the mount never puts its target's bytes on the wire; Replay carries the target.TestSec_Path_DirSymlinkIsNotServedrewritten to the new contract: listed as a link, refused on file/download/render, including paths through a symlinked directory. It failed on this branch before theOpenguard by serving the outside file.TestHubListsASyncedSymlinkAndNeverServesIt: the hub-mode twin over a pushed journal.go test ./...green locally.Architecture changes
architecture/cli-sync.md —
OpgainedLink(symlink target, no blob);walkFoldergained thevSymlinkverdict.flowchart TB Session["<div style='text-align:left'><b>Session</b><br/>+Cycle()<br/>-scan<br/>-materialize</div>"] Op["<div style='text-align:left'><b>Op</b><br/>+Seq +Lamport +Time +Device<br/>+Kind put or delete<br/>+Path +Blob +Size +Mode +Note<br/>+Mtime when the file was written<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Link symlink target, no blob</span></div>"] walkFolder["<div style='text-align:left'><b>walkFolder</b><br/>+walkFolder(folder, filter, fn)<br/>verdict: vSync vSkipFile vDescend vPruneDir vNested<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ verdict: vSymlink</span></div>"] Note["Link is the target string verbatim.<br/>Scan: Readlink, never Open.<br/>Materialize recreates the entry,<br/>never resolves it."] Session -- "scan / commit" --> Op Session -- "scan" --> walkFolder Op -.- Note classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class Note noteBoxarchitecture/webapp-server.md —
Source.Opennow refuses a link entry on both implementations (new note onSource); no types added or removed.flowchart TB Source["<div style='text-align:left'><b>Source</b> «interface»<br/>+Files(ctx) map path→FileInfo<br/>+Open(ctx, path, fi) io.ReadCloser</div>"] DirSource["<div style='text-align:left'><b>DirSource</b><br/>+Dir string</div>"] RemoteSource["<div style='text-align:left'><b>RemoteSource</b><br/>+Backend remote.Backend<br/>+OpenBlob(ctx, sha)</div>"] Note["✅ A link entry (FileInfo.Link) is listed<br/>as name + target, no size, no blob.<br/>Open refuses it on both sources;<br/>DirSource also Lstat-checks the disk.<br/>One door for viewer, download, render,<br/>shares, MCP and the co-editing room."] DirSource -. implements .-> Source RemoteSource -. implements .-> Source Source -.- Note classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px class Note added linkStyle 2 stroke:#22c55e,stroke-width:2px🤖 Generated with Claude Code