Compare commits

...
Author SHA1 Message Date
QuickandClaude Opus 4.8 37ad393536 fix(api): show symlinked directories in the folder browser
The folder browser listed children with entry.IsDir(), which reflects the
lstat type, so a symlink pointing at a directory was reported as a
non-directory and silently skipped. Symlinked library folders (e.g. Docker
bind mounts into an rclone/FUSE mount) never appeared in the picker, even
though the targets are valid, readable directories.

Follow symlink entries with os.Stat and include them when the target
resolves to a directory. Regular directories keep the fast path with no
extra syscall; symlinks to files and dangling symlinks remain excluded.

Closes #208

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:56:44 -04:00
2 changed files with 132 additions and 3 deletions
+18 -3
View File
@@ -56,14 +56,29 @@ func (h *FilesystemHandler) HandleBrowse(w http.ResponseWriter, r *http.Request)
result := make([]filesystemBrowseEntry, 0, len(entries))
for _, entry := range entries {
name := entry.Name()
childPath := filepath.Join(cleaned, name)
// entry.IsDir() reports the lstat type, so a symlink that points at a
// directory is reported as a non-directory and would be skipped here.
// For symlinks, follow the link with os.Stat and include the entry only
// when its target resolves to a directory; this keeps symlinked library
// folders browsable (issue #208) while excluding symlinks to files and
// dangling symlinks (os.Stat fails). Non-symlink, non-directory entries
// take the fast path with no extra syscall.
if !entry.IsDir() {
continue
if entry.Type()&os.ModeSymlink == 0 {
continue
}
target, err := os.Stat(childPath)
if err != nil || !target.IsDir() {
continue
}
}
name := entry.Name()
result = append(result, filesystemBrowseEntry{
Name: name,
Path: filepath.Join(cleaned, name),
Path: childPath,
})
}
+114
View File
@@ -0,0 +1,114 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
)
// browseNames runs HandleBrowse against the given path and returns the names of
// the entries reported in the response. It fails the test on any non-200 status.
func browseNames(t *testing.T, path string) []string {
t.Helper()
h := NewFilesystemHandler()
req := httptest.NewRequest(http.MethodGet, "/api/v1/filesystem/browse?path="+path, nil)
q := req.URL.Query()
q.Set("path", path)
req.URL.RawQuery = q.Encode()
rec := httptest.NewRecorder()
h.HandleBrowse(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("HandleBrowse(%q) status = %d, want 200; body=%s", path, rec.Code, rec.Body.String())
}
var resp filesystemBrowseResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v; body=%s", err, rec.Body.String())
}
names := make([]string, 0, len(resp.Entries))
for _, e := range resp.Entries {
names = append(names, e.Name)
}
return names
}
func contains(names []string, want string) bool {
for _, n := range names {
if n == want {
return true
}
}
return false
}
// TestHandleBrowseFollowsSymlinkedDirectories is the regression test for #208:
// a folder whose children include a symlink pointing at a real directory must
// list that symlink in the picker, while symlinks to files and dangling
// symlinks must be excluded.
func TestHandleBrowseFollowsSymlinkedDirectories(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink semantics differ on windows")
}
root := t.TempDir()
// Real subdirectory (control — already worked).
if err := os.Mkdir(filepath.Join(root, "real-dir"), 0o755); err != nil {
t.Fatalf("mkdir real-dir: %v", err)
}
// A regular file (must be excluded).
if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
// Target directory living outside the browsed root, then a symlink to it —
// this mirrors the issue's bind-mounted symlinks into a FUSE mount.
target := filepath.Join(t.TempDir(), "Africa (2013)")
if err := os.Mkdir(target, 0o755); err != nil {
t.Fatalf("mkdir target: %v", err)
}
if err := os.Symlink(target, filepath.Join(root, "symlink-dir")); err != nil {
t.Fatalf("symlink dir: %v", err)
}
// Symlink to a file (must be excluded — it is not a directory).
fileTarget := filepath.Join(t.TempDir(), "movie.mkv")
if err := os.WriteFile(fileTarget, []byte("x"), 0o644); err != nil {
t.Fatalf("write file target: %v", err)
}
if err := os.Symlink(fileTarget, filepath.Join(root, "symlink-file")); err != nil {
t.Fatalf("symlink file: %v", err)
}
// Dangling symlink (must be excluded — os.Stat fails).
if err := os.Symlink(filepath.Join(t.TempDir(), "missing"), filepath.Join(root, "dangling")); err != nil {
t.Fatalf("symlink dangling: %v", err)
}
names := browseNames(t, root)
if !contains(names, "real-dir") {
t.Errorf("real-dir missing from browse entries: %v", names)
}
if !contains(names, "symlink-dir") {
t.Errorf("symlink-dir (symlink to a directory) missing from browse entries: %v", names)
}
if contains(names, "file.txt") {
t.Errorf("file.txt should not appear in folder browse entries: %v", names)
}
if contains(names, "symlink-file") {
t.Errorf("symlink-file (symlink to a file) should not appear in folder browse entries: %v", names)
}
if contains(names, "dangling") {
t.Errorf("dangling symlink should not appear in folder browse entries: %v", names)
}
}