Skip to content

feat(sync): symlinks sync as links, shown in the tree, never followed - #273

Merged
ssowonny merged 1 commit into
mainfrom
feat/symlinks-in-the-tree
Oct 7, 2026
Merged

ssowonny merged 1 commit into
mainfrom
feat/symlinks-in-the-tree

Conversation

@ssowonny

@ssowonny ssowonny commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

TL;DR

  • A symlink in a mount used to vanish silently: never journaled, never on a peer.
  • Now it syncs as a link carrying its target string and no blob, so a link to a secret ships the path, never the bytes.
  • The hub lists links in the tree with their target and offers a click-through when the target is inside the project.
  • Content is never served through a link on any route; the existing security test caught the folder viewer following one, and now pins the new contract.
  • Known gap: a link's target string (which may be an absolute path on the author's machine) is visible to every project member, by design.

What changed

  • journal.Op.Link / FileState.Link carry the target; Replay folds it. store.CachedFile.Link lets change detection notice a retarget.
  • walkFolder answers a new vSymlink verdict, so scan, Explain, Measure and Drift all see links the same way. Scan uses Readlink, never Open.
  • materializeSymlink recreates the link entry on peers atomically and never resolves it; an untracked real file at that path is never clobbered.
  • Hub: DirSource lists links via Readlink; RemoteSource folds Op.Link into FileInfo.Link; the tree node carries link.
  • Both Source.Open implementations refuse a link entry (errLinkHasNoContent). DirSource also Lstat-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 through Open, so the refusal lives once.
  • Frontend: link icon and target tooltip in the tree; a SymlinkView that 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_DirSymlinkIsNotServed rewritten 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 the Open guard by serving the outside file.
  • TestHubListsASyncedSymlinkAndNeverServesIt: the hub-mode twin over a pushed journal.
  • Full go test ./... green locally.

Architecture changes

architecture/cli-sync.md — Op gained Link (symlink target, no blob); walkFolder gained the vSymlink verdict.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

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 noteBox
Loading

architecture/webapp-server.md — Source.Open now refuses a link entry on both implementations (new note on Source); no types added or removed.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

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
Loading

🤖 Generated with Claude Code

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>
@ssowonny
ssowonny merged commit b260d3a into main Oct 7, 2026
7 of 8 checks passed
@ssowonny
ssowonny deleted the feat/symlinks-in-the-tree branch October 7, 2026 04:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant