diff --git a/internal/mountfuse/dir.go b/internal/mountfuse/dir.go index bf349b2b..8179106e 100644 --- a/internal/mountfuse/dir.go +++ b/internal/mountfuse/dir.go @@ -53,7 +53,7 @@ func (n *DirNode) Getattr(ctx context.Context, _ gofusefs.FileHandle, out *fuse. } func (n *DirNode) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*gofusefs.Inode, syscall.Errno) { - if child := n.lookupCachedChild(name); child != nil { + if child := n.GetChild(name); child != nil { if existing, ok := child.Operations().(interface { fillEntry(*fuse.EntryOut) syscall.Errno }); ok { @@ -67,6 +67,7 @@ func (n *DirNode) Lookup(ctx context.Context, name string, out *fuse.EntryOut) ( if err != nil { return nil, readErrno(err) } + // Alias directories traverse through the same lookup path as any other child. meta, _, ok := resolveDirectoryEntry(entries, name) if !ok { out.SetEntryTimeout(n.state.negativeTTL) @@ -135,7 +136,7 @@ func (n *DirNode) Unlink(ctx context.Context, name string) syscall.Errno { if err != nil { return writeErrno(err) } - meta, resolvedName, ok := resolveDirectoryEntry(entries, name) + meta, _, ok := resolveDirectoryEntry(entries, name) if !ok { return syscall.ENOENT } @@ -154,11 +155,7 @@ func (n *DirNode) Unlink(ctx context.Context, name string) syscall.Errno { return writeErrno(err) } n.state.invalidate(meta.path) - if resolvedName == name || n.GetChild(name) == nil { - n.RmChild(resolvedName) - return 0 - } - n.RmChild(resolvedName, name) + n.RmChild(name) return 0 } @@ -171,19 +168,3 @@ func (n *DirNode) fillEntry(out *fuse.EntryOut) syscall.Errno { meta.fillEntry(out, n.state) return 0 } - -func (n *DirNode) lookupCachedChild(name string) *gofusefs.Inode { - if child := n.GetChild(name); child != nil { - return child - } - children := n.Children() - names := make([]string, 0, len(children)) - for childName := range children { - names = append(names, childName) - } - resolvedName, ok := resolveNameByID(names, name) - if !ok { - return nil - } - return n.GetChild(resolvedName) -} diff --git a/internal/mountfuse/fs.go b/internal/mountfuse/fs.go index 149a9b5f..01487105 100644 --- a/internal/mountfuse/fs.go +++ b/internal/mountfuse/fs.go @@ -325,6 +325,8 @@ func (s *fsState) listDirectory(ctx context.Context, remotePath string) (map[str entries[meta.name] = meta } if remotePath == s.remoteRoot { + // Alias directories such as by-title/by-id are adapter-owned remote + // entries; the only synthesized root entry here is the virtual layout. entries[layoutFilename] = virtualLayoutMeta(s.remoteRoot) } s.putDir(remotePath, entries) @@ -413,8 +415,13 @@ func (s *fsState) treeEntryToMeta(entry mountsync.TreeEntry, parentPath string) return nodeMeta{}, false } mode := uint32(syscall.S_IFREG | defaultFileMode) - if strings.EqualFold(entry.Type, "directory") || strings.EqualFold(entry.Type, "dir") { + switch { + case strings.EqualFold(entry.Type, "directory"), strings.EqualFold(entry.Type, "dir"): mode = syscall.S_IFDIR | defaultDirMode + case strings.EqualFold(entry.Type, "symlink"): + // Forward compatibility: if a future adapter emits symlink-like alias + // entries, surface them as readable files and let lazy reads populate + // size/revision details from ReadFile. } return nodeMeta{ path: childPath, diff --git a/internal/mountfuse/fuse_test.go b/internal/mountfuse/fuse_test.go index f1d791c1..059a2632 100644 --- a/internal/mountfuse/fuse_test.go +++ b/internal/mountfuse/fuse_test.go @@ -3,6 +3,7 @@ package mountfuse import ( "context" "sort" + "strings" "syscall" "testing" @@ -18,8 +19,6 @@ type fakeRemoteClient struct { files map[string]mountsync.RemoteFile trees map[string]mountsync.TreeResponse - deleteCalls []deleteCall - // Optional error injection — when non-nil the corresponding method // returns this error instead of looking up data. readFileErr error @@ -28,11 +27,6 @@ type fakeRemoteClient struct { listTreeErr error } -type deleteCall struct { - path string - baseRevision string -} - func (f *fakeRemoteClient) ListTree(_ context.Context, _, path string, _ int, _ string) (mountsync.TreeResponse, error) { if f.listTreeErr != nil { return mountsync.TreeResponse{}, f.listTreeErr @@ -77,7 +71,8 @@ func (f *fakeRemoteClient) DeleteFile(_ context.Context, _, path, baseRevision s if f.deleteErr != nil { return f.deleteErr } - f.deleteCalls = append(f.deleteCalls, deleteCall{path: path, baseRevision: baseRevision}) + _ = path + _ = baseRevision return nil } @@ -270,135 +265,247 @@ func TestMapError(t *testing.T) { } } -func TestDirNodeLookupAndReadAcceptsLegacyAndNameIDForms(t *testing.T) { +func TestFuseAliasByTitleResolves(t *testing.T) { t.Parallel() + const ( + workspaceID = "ws_alias_by_title" + jsonBody = `{"id":"page-123","title":"Foo"}` + spaceBody = `{"id":"page-456","title":"Khaliq's To Dos"}` + unicodeBody = `{"id":"page-789","title":"unicode alias"}` + plainBody = "plain alias body" + ) + remote := &fakeRemoteClient{ trees: map[string]mountsync.TreeResponse{ "/": { Path: "/", Entries: []mountsync.TreeEntry{ - {Path: "/legacy-id.json", Type: "file", Revision: "r_legacy"}, - {Path: "/human-name__legacy-id.json", Type: "file", Revision: "r_named"}, - {Path: "/only-human__shared-id.json", Type: "file", Revision: "r_shared"}, + {Path: "/notion", Type: "directory"}, + }, + }, + "/notion": { + Path: "/notion", + Entries: []mountsync.TreeEntry{ + {Path: "/notion/pages", Type: "directory"}, + }, + }, + "/notion/pages": { + Path: "/notion/pages", + Entries: []mountsync.TreeEntry{ + {Path: "/notion/pages/" + aliasByTitleSegment, Type: "directory"}, + {Path: "/notion/pages/page-123.json", Type: "file", Revision: "r-page"}, + }, + }, + "/notion/pages/" + aliasByTitleSegment: { + Path: "/notion/pages/" + aliasByTitleSegment, + Entries: []mountsync.TreeEntry{ + {Path: "/notion/pages/" + aliasByTitleSegment + "/foo.json", Type: "file", Revision: "r-foo"}, + {Path: "/notion/pages/" + aliasByTitleSegment + "/Khaliq's To Dos.json", Type: "file", Revision: "r-space"}, + {Path: "/notion/pages/" + aliasByTitleSegment + "/Smørbrød-årsplan.json", Type: "file", Revision: "r-unicode"}, + {Path: "/notion/pages/" + aliasByTitleSegment + "/Foo Bar", Type: "file", Revision: "r-plain"}, }, }, }, files: map[string]mountsync.RemoteFile{ - "/legacy-id.json": { - Path: "/legacy-id.json", - Revision: "r_legacy", + "/notion/pages/" + aliasByTitleSegment + "/foo.json": { + Path: "/notion/pages/" + aliasByTitleSegment + "/foo.json", + Revision: "r-foo", ContentType: "application/json", - Content: `{"kind":"legacy"}`, + Content: jsonBody, }, - "/human-name__legacy-id.json": { - Path: "/human-name__legacy-id.json", - Revision: "r_named", - ContentType: "application/json", - Content: `{"kind":"named"}`, + "/notion/pages/" + aliasByTitleSegment + "/Khaliq's To Dos.json": { + Path: "/notion/pages/" + aliasByTitleSegment + "/Khaliq's To Dos.json", + Revision: "r-space", + ContentType: "application/vnd.alias+json", + Content: spaceBody, }, - "/only-human__shared-id.json": { - Path: "/only-human__shared-id.json", - Revision: "r_shared", + "/notion/pages/" + aliasByTitleSegment + "/Smørbrød-årsplan.json": { + Path: "/notion/pages/" + aliasByTitleSegment + "/Smørbrød-årsplan.json", + Revision: "r-unicode", ContentType: "application/json", - Content: `{"kind":"shared"}`, + Content: unicodeBody, + }, + "/notion/pages/" + aliasByTitleSegment + "/Foo Bar": { + Path: "/notion/pages/" + aliasByTitleSegment + "/Foo Bar", + Revision: "r-plain", + Content: plainBody, }, }, } - root, err := New(Config{Client: remote, WorkspaceID: "ws_nameid", RemoteRoot: "/"}) - if err != nil { - t.Fatalf("New() failed: %v", err) - } - _ = gofusefs.NewNodeFS(root, &gofusefs.Options{}) + root := newMountTestRoot(t, remote, workspaceID) + notion := lookupDir(t, root, "notion") + pages := lookupDir(t, notion, "pages") + aliases := lookupDir(t, pages, aliasByTitleSegment) - ctx := context.Background() - stream, errno := root.Readdir(ctx) - if errno != 0 { - t.Fatalf("Readdir errno = %d, want 0", errno) + tests := []struct { + name string + filename string + wantContent string + wantContentType string + }{ + { + name: "json alias", + filename: "foo.json", + wantContent: jsonBody, + wantContentType: "application/json", + }, + { + name: "space and apostrophe alias", + filename: "Khaliq's To Dos.json", + wantContent: spaceBody, + wantContentType: "application/vnd.alias+json", + }, + { + name: "unicode alias", + filename: "Smørbrød-årsplan.json", + wantContent: unicodeBody, + wantContentType: "application/json", + }, + { + name: "no extension alias", + filename: "Foo Bar", + wantContent: plainBody, + wantContentType: contentTypeForPath("/notion/pages/" + aliasByTitleSegment + "/Foo Bar"), + }, } - defer stream.Close() - var gotNames []string - for stream.HasNext() { - entry, nextErrno := stream.Next() - if nextErrno != 0 { - t.Fatalf("Readdir.Next errno = %d, want 0", nextErrno) - } - gotNames = append(gotNames, entry.Name) - } - sort.Strings(gotNames) - wantNames := []string{ - "LAYOUT.md", - "human-name__legacy-id.json", - "legacy-id.json", - "only-human__shared-id.json", - } - if len(gotNames) != len(wantNames) { - t.Fatalf("Readdir names = %v, want %v", gotNames, wantNames) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fileNode, entryOut := lookupFile(t, aliases, tt.filename) + if entryOut.Attr.Mode&syscall.S_IFMT != syscall.S_IFREG { + t.Fatalf("Lookup(%q) mode = %o, want regular file", tt.filename, entryOut.Attr.Mode) + } + gotContent, gotContentType := readFileContent(t, fileNode) + if gotContent != tt.wantContent { + t.Fatalf("read %q = %q, want %q", tt.filename, gotContent, tt.wantContent) + } + if gotContentType != tt.wantContentType { + t.Fatalf("content type for %q = %q, want %q", tt.filename, gotContentType, tt.wantContentType) + } + }) } - for i := range wantNames { - if gotNames[i] != wantNames[i] { - t.Fatalf("Readdir names = %v, want %v", gotNames, wantNames) - } +} + +func TestFuseAliasByIDResolves(t *testing.T) { + t.Parallel() + + const body = `{"identifier":"AGE-8","title":"Alias by ID"}` + + remote := &fakeRemoteClient{ + trees: map[string]mountsync.TreeResponse{ + "/": { + Path: "/", + Entries: []mountsync.TreeEntry{ + {Path: "/linear", Type: "directory"}, + }, + }, + "/linear": { + Path: "/linear", + Entries: []mountsync.TreeEntry{ + {Path: "/linear/issues", Type: "directory"}, + }, + }, + "/linear/issues": { + Path: "/linear/issues", + Entries: []mountsync.TreeEntry{ + {Path: "/linear/issues/" + aliasByIDSegment, Type: "directory"}, + }, + }, + "/linear/issues/" + aliasByIDSegment: { + Path: "/linear/issues/" + aliasByIDSegment, + Entries: []mountsync.TreeEntry{ + {Path: "/linear/issues/" + aliasByIDSegment + "/AGE-8.json", Type: "file", Revision: "r-age-8"}, + }, + }, + }, + files: map[string]mountsync.RemoteFile{ + "/linear/issues/" + aliasByIDSegment + "/AGE-8.json": { + Path: "/linear/issues/" + aliasByIDSegment + "/AGE-8.json", + Revision: "r-age-8", + ContentType: "application/json", + Content: body, + }, + }, } - readByName := func(name string) string { - t.Helper() - var entryOut fuse.EntryOut - child, lookupErrno := root.Lookup(ctx, name, &entryOut) - if lookupErrno != 0 { - t.Fatalf("Lookup(%q) errno = %d, want 0", name, lookupErrno) - } - fileNode, ok := child.Operations().(*FileNode) - if !ok { - t.Fatalf("Lookup(%q) returned %T, want *FileNode", name, child.Operations()) - } - handle, _, openErrno := fileNode.Open(ctx, 0) - if openErrno != 0 { - t.Fatalf("Open(%q) errno = %d, want 0", name, openErrno) - } - fileHandle, ok := handle.(*FileHandle) - if !ok { - t.Fatalf("Open(%q) returned %T, want *FileHandle", name, handle) - } - result, readErrno := fileHandle.Read(ctx, make([]byte, 1024), 0) - if readErrno != 0 { - t.Fatalf("Read(%q) errno = %d, want 0", name, readErrno) - } - data, status := result.Bytes(nil) - if status != 0 { - t.Fatalf("Read(%q) status = %d, want 0", name, status) - } - result.Done() - return string(data) + root := newMountTestRoot(t, remote, "ws_alias_by_id") + linear := lookupDir(t, root, "linear") + issues := lookupDir(t, linear, "issues") + aliases := lookupDir(t, issues, aliasByIDSegment) + fileNode, _ := lookupFile(t, aliases, "AGE-8.json") + gotContent, _ := readFileContent(t, fileNode) + if gotContent != body { + t.Fatalf("read AGE-8.json = %q, want %q", gotContent, body) } +} - if got := readByName("legacy-id.json"); got != `{"kind":"named"}` { - t.Fatalf("read legacy alias with canonical sibling = %q, want %q", got, `{"kind":"named"}`) +func TestFuseAliasReaddirIncludesByTitle(t *testing.T) { + t.Parallel() + + remote := &fakeRemoteClient{ + trees: map[string]mountsync.TreeResponse{ + "/": { + Path: "/", + Entries: []mountsync.TreeEntry{ + {Path: "/notion", Type: "directory"}, + }, + }, + "/notion": { + Path: "/notion", + Entries: []mountsync.TreeEntry{ + {Path: "/notion/pages", Type: "directory"}, + }, + }, + "/notion/pages": { + Path: "/notion/pages", + Entries: []mountsync.TreeEntry{ + {Path: "/notion/pages/" + aliasByTitleSegment, Type: "directory"}, + {Path: "/notion/pages/page-123.json", Type: "file", Revision: "r-page"}, + }, + }, + "/notion/pages/" + aliasByTitleSegment: { + Path: "/notion/pages/" + aliasByTitleSegment, + Entries: nil, + }, + }, + files: map[string]mountsync.RemoteFile{ + "/notion/pages/page-123.json": { + Path: "/notion/pages/page-123.json", + Revision: "r-page", + ContentType: "application/json", + Content: `{"id":"page-123"}`, + }, + }, } - if got := readByName("human-name__legacy-id.json"); got != `{"kind":"named"}` { - t.Fatalf("read named file = %q, want %q", got, `{"kind":"named"}`) + + root := newMountTestRoot(t, remote, "ws_alias_readdir") + notion := lookupDir(t, root, "notion") + pages := lookupDir(t, notion, "pages") + + names := readdirNames(t, pages) + if !equalSorted(names, []string{aliasByTitleSegment, "page-123.json"}) { + t.Fatalf("Readdir(/notion/pages) = %v, want %v", names, []string{aliasByTitleSegment, "page-123.json"}) } - // Prime the child cache under the canonical new-style filename, then - // prove a legacy-style lookup still resolves through the same entity ID. - if got := readByName(nameWithId("only-human", "shared-id")); got != `{"kind":"shared"}` { - t.Fatalf("read canonical shared file = %q, want %q", got, `{"kind":"shared"}`) + aliasDir := lookupDir(t, pages, aliasByTitleSegment) + fileNode, entryOut := lookupFile(t, pages, "page-123.json") + if entryOut.Attr.Mode&syscall.S_IFMT != syscall.S_IFREG { + t.Fatalf("Lookup(page-123.json) mode = %o, want regular file", entryOut.Attr.Mode) } - if got := readByName("shared-id.json"); got != `{"kind":"shared"}` { - t.Fatalf("read legacy alias = %q, want %q", got, `{"kind":"shared"}`) + if _, ok := aliasDir.Operations().(*DirNode); !ok { + t.Fatalf("Lookup(%q) returned non-directory child", aliasByTitleSegment) } - - if legacyID := IDFromBasename("legacy-id.json"); legacyID != "legacy-id" { - t.Fatalf("IDFromBasename(legacy-id.json) = %q, want %q", legacyID, "legacy-id") + if got := readdirNames(t, aliasDir); len(got) != 0 { + t.Fatalf("Readdir(%q) = %v, want empty alias directory", aliasByTitleSegment, got) } - if namedID := IDFromBasename("human-name__legacy-id.json"); namedID != "legacy-id" { - t.Fatalf("IDFromBasename(human-name__legacy-id.json) = %q, want %q", namedID, "legacy-id") + if content, _ := readFileContent(t, fileNode); content != `{"id":"page-123"}` { + t.Fatalf("read page-123.json = %q, want page content", content) } } -func TestDirNodeLookupAcceptsNameIDDirectories(t *testing.T) { +func TestFuseAliasCollisionWithRealFile(t *testing.T) { t.Parallel() remote := &fakeRemoteClient{ @@ -406,130 +513,258 @@ func TestDirNodeLookupAcceptsNameIDDirectories(t *testing.T) { "/": { Path: "/", Entries: []mountsync.TreeEntry{ - {Path: "/thread__01HXYZ", Type: "directory"}, + {Path: "/notion", Type: "directory"}, + }, + }, + "/notion": { + Path: "/notion", + Entries: []mountsync.TreeEntry{ + {Path: "/notion/pages", Type: "directory"}, + }, + }, + "/notion/pages": { + Path: "/notion/pages", + Entries: []mountsync.TreeEntry{ + {Path: "/notion/pages/" + aliasByTitleSegment, Type: "directory"}, }, }, - "/thread__01HXYZ": { - Path: "/thread__01HXYZ", + "/notion/pages/" + aliasByTitleSegment: { + Path: "/notion/pages/" + aliasByTitleSegment, Entries: []mountsync.TreeEntry{ - {Path: "/thread__01HXYZ/note.json", Type: "file", Revision: "r_note"}, + {Path: "/notion/pages/" + aliasByTitleSegment + "/foo.json", Type: "file", Revision: "r-foo"}, + {Path: "/notion/pages/" + aliasByTitleSegment + "/foo.json.bak", Type: "file", Revision: "r-foo-bak"}, }, }, }, files: map[string]mountsync.RemoteFile{ - "/thread__01HXYZ/note.json": { - Path: "/thread__01HXYZ/note.json", - Revision: "r_note", + "/notion/pages/" + aliasByTitleSegment + "/foo.json": { + Path: "/notion/pages/" + aliasByTitleSegment + "/foo.json", + Revision: "r-foo", ContentType: "application/json", - Content: `{"note":"ok"}`, + Content: `{"name":"foo"}`, + }, + "/notion/pages/" + aliasByTitleSegment + "/foo.json.bak": { + Path: "/notion/pages/" + aliasByTitleSegment + "/foo.json.bak", + Revision: "r-foo-bak", + ContentType: "application/octet-stream", + Content: "backup", }, }, } - root, err := New(Config{Client: remote, WorkspaceID: "ws_nameid_dirs", RemoteRoot: "/"}) - if err != nil { - t.Fatalf("New() failed: %v", err) + root := newMountTestRoot(t, remote, "ws_alias_collision") + aliases := lookupDir(t, lookupDir(t, lookupDir(t, root, "notion"), "pages"), aliasByTitleSegment) + + names := readdirNames(t, aliases) + if !equalSorted(names, []string{"foo.json", "foo.json.bak"}) { + t.Fatalf("Readdir(alias dir) = %v, want both colliding names", names) } - _ = gofusefs.NewNodeFS(root, &gofusefs.Options{}) - ctx := context.Background() - for _, name := range []string{"thread__01HXYZ", "01HXYZ"} { - t.Run(name, func(t *testing.T) { - var entryOut fuse.EntryOut - child, errno := root.Lookup(ctx, name, &entryOut) - if errno != 0 { - t.Fatalf("Lookup(%q) errno = %d, want 0", name, errno) - } - dirNode, ok := child.Operations().(*DirNode) - if !ok { - t.Fatalf("Lookup(%q) returned %T, want *DirNode", name, child.Operations()) - } - var nestedOut fuse.EntryOut - nested, nestedErrno := dirNode.Lookup(ctx, "note.json", &nestedOut) - if nestedErrno != 0 { - t.Fatalf("Lookup(%q)/note.json errno = %d, want 0", name, nestedErrno) - } - if _, ok := nested.Operations().(*FileNode); !ok { - t.Fatalf("Lookup(%q)/note.json returned %T, want *FileNode", name, nested.Operations()) - } - }) + foo, _ := lookupFile(t, aliases, "foo.json") + fooBak, _ := lookupFile(t, aliases, "foo.json.bak") + if got, _ := readFileContent(t, foo); got != `{"name":"foo"}` { + t.Fatalf("read foo.json = %q, want primary content", got) + } + if got, _ := readFileContent(t, fooBak); got != "backup" { + t.Fatalf("read foo.json.bak = %q, want backup content", got) } } -func TestDirNodeUnlinkAcceptsLegacyAndNameIDForms(t *testing.T) { +func TestFuseAliasMissingDirectoryReturnsENOENT(t *testing.T) { t.Parallel() - tests := []struct { - name string - deleteName string - primeLookup string - }{ - { - name: "canonical unlink", - deleteName: "only-human__shared-id.json", - primeLookup: "only-human__shared-id.json", + root := newMountTestRoot(t, &fakeRemoteClient{ + trees: map[string]mountsync.TreeResponse{ + "/": { + Path: "/", + Entries: []mountsync.TreeEntry{ + {Path: "/notion", Type: "directory"}, + }, + }, + "/notion": { + Path: "/notion", + Entries: []mountsync.TreeEntry{ + {Path: "/notion/pages", Type: "directory"}, + }, + }, + "/notion/pages": { + Path: "/notion/pages", + Entries: []mountsync.TreeEntry{ + {Path: "/notion/pages/page-123.json", Type: "file", Revision: "r-page"}, + }, + }, }, - { - name: "legacy alias unlink", - deleteName: "shared-id.json", - primeLookup: "only-human__shared-id.json", + }, "ws_alias_missing") + + pages := lookupDir(t, lookupDir(t, root, "notion"), "pages") + if names := readdirNames(t, pages); !equalSorted(names, []string{"page-123.json"}) { + t.Fatalf("Readdir(/notion/pages) = %v, want no synthesized alias directories", names) + } + + var missingOut fuse.EntryOut + if _, errno := pages.Lookup(context.Background(), aliasByTitleSegment, &missingOut); errno != syscall.ENOENT { + t.Fatalf("Lookup(%q) errno = %d, want ENOENT", aliasByTitleSegment, errno) + } + if got := missingOut.EntryTimeout(); got != pages.state.negativeTTL { + t.Fatalf("negative lookup timeout = %s, want %s", got, pages.state.negativeTTL) + } + + if _, lookupErrno := root.Lookup(context.Background(), layoutFilename, &fuse.EntryOut{}); lookupErrno != 0 { + t.Fatalf("Lookup(%q) after missing alias lookup errno = %d, want 0", layoutFilename, lookupErrno) + } +} + +func TestFuseAliasReaddirRefreshesAfterInvalidation(t *testing.T) { + t.Parallel() + + remote := &fakeRemoteClient{ + trees: map[string]mountsync.TreeResponse{ + "/": { + Path: "/", + Entries: []mountsync.TreeEntry{ + {Path: "/notion", Type: "directory"}, + }, + }, + "/notion": { + Path: "/notion", + Entries: []mountsync.TreeEntry{ + {Path: "/notion/pages", Type: "directory"}, + }, + }, + "/notion/pages": { + Path: "/notion/pages", + Entries: []mountsync.TreeEntry{ + {Path: "/notion/pages/page-123.json", Type: "file", Revision: "r-page"}, + }, + }, }, - { - name: "legacy alias unlink without cache prime", - deleteName: "shared-id.json", + } + + root := newMountTestRoot(t, remote, "ws_alias_invalidate") + pages := lookupDir(t, lookupDir(t, root, "notion"), "pages") + if names := readdirNames(t, pages); !equalSorted(names, []string{"page-123.json"}) { + t.Fatalf("initial Readdir(/notion/pages) = %v, want page entry only", names) + } + + remote.trees["/notion/pages"] = mountsync.TreeResponse{ + Path: "/notion/pages", + Entries: []mountsync.TreeEntry{ + {Path: "/notion/pages/" + aliasByTitleSegment, Type: "directory"}, + {Path: "/notion/pages/page-123.json", Type: "file", Revision: "r-page"}, }, } + remote.trees["/notion/pages/"+aliasByTitleSegment] = mountsync.TreeResponse{ + Path: "/notion/pages/" + aliasByTitleSegment, + Entries: nil, + } + root.state.invalidate("/notion/pages") - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - remote := &fakeRemoteClient{ - trees: map[string]mountsync.TreeResponse{ - "/": { - Path: "/", - Entries: []mountsync.TreeEntry{ - {Path: "/only-human__shared-id.json", Type: "file", Revision: "r_shared"}, - }, - }, - }, - } + if names := readdirNames(t, pages); !equalSorted(names, []string{aliasByTitleSegment, "page-123.json"}) { + t.Fatalf("Readdir(/notion/pages) after invalidate = %v, want refreshed alias dir", names) + } +} - root, err := New(Config{Client: remote, WorkspaceID: "ws_nameid_unlink", RemoteRoot: "/"}) - if err != nil { - t.Fatalf("New() failed: %v", err) - } - _ = gofusefs.NewNodeFS(root, &gofusefs.Options{}) - - ctx := context.Background() - if tt.primeLookup != "" { - var entryOut fuse.EntryOut - child, errno := root.Lookup(ctx, tt.primeLookup, &entryOut) - if errno != 0 { - t.Fatalf("Lookup(%q) errno = %d, want 0", tt.primeLookup, errno) - } - root.AddChild("only-human__shared-id.json", child, true) - if root.GetChild("only-human__shared-id.json") == nil { - t.Fatalf("expected canonical child cache to be populated before unlink") - } - } else if root.GetChild("only-human__shared-id.json") != nil { - t.Fatalf("expected canonical child cache to start empty without a prime lookup") - } +func newMountTestRoot(t *testing.T, remote *fakeRemoteClient, workspaceID string) *DirNode { + t.Helper() - errno := root.Unlink(ctx, tt.deleteName) - if errno != 0 { - t.Fatalf("Unlink(%q) errno = %d, want 0", tt.deleteName, errno) - } - if len(remote.deleteCalls) != 1 { - t.Fatalf("DeleteFile calls = %d, want 1", len(remote.deleteCalls)) - } - if remote.deleteCalls[0].path != "/only-human__shared-id.json" { - t.Fatalf("DeleteFile path = %q, want %q", remote.deleteCalls[0].path, "/only-human__shared-id.json") - } - if remote.deleteCalls[0].baseRevision != "r_shared" { - t.Fatalf("DeleteFile baseRevision = %q, want %q", remote.deleteCalls[0].baseRevision, "r_shared") - } - if root.GetChild("only-human__shared-id.json") != nil { - t.Fatalf("expected canonical child cache to be cleared after unlink") - } - }) + root, err := New(Config{Client: remote, WorkspaceID: workspaceID, RemoteRoot: "/"}) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + _ = gofusefs.NewNodeFS(root, &gofusefs.Options{}) + return root +} + +func lookupDir(t *testing.T, parent *DirNode, name string) *DirNode { + t.Helper() + + child, errno, out := lookupChild(t, parent, name) + if errno != 0 { + t.Fatalf("Lookup(%q) errno = %d, want 0", name, errno) + } + if out.Attr.Mode&syscall.S_IFMT != syscall.S_IFDIR { + t.Fatalf("Lookup(%q) mode = %o, want directory", name, out.Attr.Mode) + } + dir, ok := child.Operations().(*DirNode) + if !ok { + t.Fatalf("Lookup(%q) returned %T, want *DirNode", name, child.Operations()) + } + return dir +} + +func lookupFile(t *testing.T, parent *DirNode, name string) (*FileNode, fuse.EntryOut) { + t.Helper() + + child, errno, out := lookupChild(t, parent, name) + if errno != 0 { + t.Fatalf("Lookup(%q) errno = %d, want 0", name, errno) + } + fileNode, ok := child.Operations().(*FileNode) + if !ok { + t.Fatalf("Lookup(%q) returned %T, want *FileNode", name, child.Operations()) + } + return fileNode, out +} + +func lookupChild(t *testing.T, parent *DirNode, name string) (*gofusefs.Inode, syscall.Errno, fuse.EntryOut) { + t.Helper() + + var out fuse.EntryOut + child, errno := parent.Lookup(context.Background(), name, &out) + return child, errno, out +} + +func readdirNames(t *testing.T, dir *DirNode) []string { + t.Helper() + + stream, errno := dir.Readdir(context.Background()) + if errno != 0 { + t.Fatalf("Readdir(%q) errno = %d, want 0", dir.path, errno) + } + defer stream.Close() + + var names []string + for stream.HasNext() { + entry, nextErrno := stream.Next() + if nextErrno != 0 { + t.Fatalf("Readdir(%q).Next errno = %d, want 0", dir.path, nextErrno) + } + names = append(names, entry.Name) + } + sort.Strings(names) + return names +} + +func readFileContent(t *testing.T, fileNode *FileNode) (string, string) { + t.Helper() + + handle, _, errno := fileNode.Open(context.Background(), 0) + if errno != 0 { + t.Fatalf("Open(%q) errno = %d, want 0", fileNode.path, errno) + } + fileHandle, ok := handle.(*FileHandle) + if !ok { + t.Fatalf("Open(%q) returned %T, want *FileHandle", fileNode.path, handle) + } + result, readErrno := fileHandle.Read(context.Background(), make([]byte, len(fileHandle.buf)+16), 0) + if readErrno != 0 { + t.Fatalf("Read(%q) errno = %d, want 0", fileNode.path, readErrno) + } + data, status := result.Bytes(nil) + if status != 0 { + t.Fatalf("Read(%q) status = %d, want 0", fileNode.path, status) + } + result.Done() + return string(data), fileHandle.contentType +} + +func equalSorted(got, want []string) bool { + if len(got) != len(want) { + return false } + gotCopy := append([]string(nil), got...) + wantCopy := append([]string(nil), want...) + sort.Strings(gotCopy) + sort.Strings(wantCopy) + return strings.Join(gotCopy, "\x00") == strings.Join(wantCopy, "\x00") } diff --git a/internal/mountfuse/layout.go b/internal/mountfuse/layout.go index 03d50fd5..58ab992b 100644 --- a/internal/mountfuse/layout.go +++ b/internal/mountfuse/layout.go @@ -8,9 +8,12 @@ import ( ) const ( - layoutFilename = "LAYOUT.md" - layoutRevision = "virtual-layout" - layoutContentType = "text/markdown; charset=utf-8" + layoutFilename = "LAYOUT.md" + layoutRevision = "virtual-layout" + layoutContentType = "text/markdown; charset=utf-8" + aliasByTitleSegment = "by-title" + aliasByIDSegment = "by-id" + aliasByNameSegment = "by-name" ) const LayoutMarkdown = `# LAYOUT @@ -25,6 +28,16 @@ This mount exposes upstream files together with navigation helpers. To ` + "`find by title`" + `, read the relevant ` + "`_index.json`" + ` file, find the matching row, then read the named file from that row. +Direct title aliases also live under ` + "`notion/pages/" + aliasByTitleSegment + "/.json`" + ` when that integration exports them. + +## Find by id + +Direct identifier aliases live under ` + "`linear/issues/" + aliasByIDSegment + "/<identifier>.json`" + ` when that integration exports them. + +## Find by name + +Direct name aliases live under ` + "`linear/users/" + aliasByNameSegment + "/<name>.json`" + ` and ` + "`github/repos/" + aliasByNameSegment + "/<owner>__<repo>.json`" + ` when those integrations export them. + ## Filenames Entity files use the ` + "`<sanitized-name>__<id>`" + ` filename convention. Recover the id from the last ` + "`__`" + `-separated segment. @@ -32,10 +45,6 @@ Entity files use the ` + "`<sanitized-name>__<id>`" + ` filename convention. Rec ## Integration-specific layouts See per-integration ` + "`<integration>/.layout.md`" + ` files for integration-specific tree shapes. - -## Forward note - -Direct by-title / by-id aliases land in a later release. ` func layoutRemotePath(remoteRoot string) string { @@ -50,7 +59,9 @@ func virtualLayoutMeta(remoteRoot string) nodeMeta { return nodeMeta{ path: layoutRemotePath(remoteRoot), name: layoutFilename, - mode: syscall.S_IFREG | defaultFileMode, + // LAYOUT.md is a virtual read-only file; writes/deletes are not + // supported, so advertise 0o444 to surface that in the mount. + mode: syscall.S_IFREG | 0o444, revision: layoutRevision, size: uint64(len(LayoutMarkdown)), modTime: time.Unix(0, 0).UTC(), diff --git a/internal/mountfuse/layout_test.go b/internal/mountfuse/layout_test.go index a3350c0b..d9f91e60 100644 --- a/internal/mountfuse/layout_test.go +++ b/internal/mountfuse/layout_test.go @@ -55,9 +55,15 @@ func TestLayoutMarkdownContainsRequiredAnchors(t *testing.T) { "linear/issues/_index.json", "github/repos/_index.json", "find by title", + "by-title", + "by-id", + "by-name", + "notion/pages/by-title/", + "linear/issues/by-id/", + "linear/users/by-name/", + "github/repos/by-name/", "__", "<integration>/.layout.md", - "by-title / by-id aliases land in a later release", } for _, needle := range required { if !strings.Contains(LayoutMarkdown, needle) { @@ -108,6 +114,9 @@ func TestRootDirectorySynthesizesLayoutMarkdown(t *testing.T) { if entryOut.Attr.Mode&syscall.S_IFMT != syscall.S_IFREG { t.Fatalf("Lookup(%q) mode = %o, want regular file", layoutFilename, entryOut.Attr.Mode) } + if perm := entryOut.Attr.Mode & 0o777; perm != 0o444 { + t.Fatalf("Lookup(%q) perm = %o, want 0444 (read-only)", layoutFilename, perm) + } if entryOut.Attr.Size != uint64(len(LayoutMarkdown)) { t.Fatalf("Lookup(%q) size = %d, want %d", layoutFilename, entryOut.Attr.Size, len(LayoutMarkdown)) }