// Package browser safely lists directories within configured filesystem roots. package browser import ( "errors" "fmt" "os" "path/filepath" "strings" "time" "webfilebrowser/internal/config" ) var ( // ErrRootNotFound indicates that a requested configured root does not exist. ErrRootNotFound = errors.New("configured root not found") // ErrPathOutsideRoot indicates traversal or a symlink that leaves a root. ErrPathOutsideRoot = errors.New("path is outside the configured root") // ErrDirectoryNotFound indicates that an allowed path is not a directory. ErrDirectoryNotFound = errors.New("directory not found") ) // Entry describes a safely listed file or directory. type Entry struct { Name string IsDirectory bool Size int64 ModifiedAt time.Time } // Directory is a configured root and a directory path within it. type Directory struct { Root config.Root RelativePath string Entries []Entry } // Browser provides root-confined directory listings. type Browser struct { rootsByName map[string]config.Root roots []config.Root } // New prepares a browser for the supplied configured roots. func New(roots []config.Root) (*Browser, error) { browser := &Browser{ rootsByName: make(map[string]config.Root, len(roots)), roots: append([]config.Root(nil), roots...), } for _, root := range roots { if _, exists := browser.rootsByName[root.Name]; exists { return nil, fmt.Errorf("duplicate configured root %q", root.Name) } browser.rootsByName[root.Name] = root } return browser, nil } // Roots returns configured roots in their configuration order. func (browser *Browser) Roots() []config.Root { return append([]config.Root(nil), browser.roots...) } // List returns the entries in a configured root directory. The requested path // cannot escape a root through parent-directory traversal or symlinks. func (browser *Browser) List(rootName, relativePath string) (Directory, error) { root, exists := browser.rootsByName[rootName] if !exists { return Directory{}, ErrRootNotFound } cleanRelativePath, err := cleanRelativePath(relativePath) if err != nil { return Directory{}, err } resolvedRoot, err := filepath.EvalSymlinks(root.Path) if err != nil { return Directory{}, fmt.Errorf("%w: %v", ErrDirectoryNotFound, err) } rootInfo, err := os.Stat(resolvedRoot) if err != nil || !rootInfo.IsDir() { return Directory{}, ErrDirectoryNotFound } candidate := root.Path if cleanRelativePath != "" { candidate = filepath.Join(root.Path, filepath.FromSlash(cleanRelativePath)) } resolvedCandidate, err := filepath.EvalSymlinks(candidate) if err != nil { return Directory{}, fmt.Errorf("%w: %v", ErrDirectoryNotFound, err) } if !isWithin(resolvedRoot, resolvedCandidate) { return Directory{}, ErrPathOutsideRoot } candidateInfo, err := os.Stat(resolvedCandidate) if err != nil || !candidateInfo.IsDir() { return Directory{}, ErrDirectoryNotFound } entries, err := os.ReadDir(candidate) if err != nil { return Directory{}, fmt.Errorf("read directory: %w", err) } listedEntries := make([]Entry, 0, len(entries)) for _, entry := range entries { entryPath := filepath.Join(candidate, entry.Name()) resolvedEntry, err := filepath.EvalSymlinks(entryPath) if err != nil || !isWithin(resolvedRoot, resolvedEntry) { continue } info, err := os.Stat(resolvedEntry) if err != nil { continue } listedEntries = append(listedEntries, Entry{ Name: entry.Name(), IsDirectory: info.IsDir(), Size: info.Size(), ModifiedAt: info.ModTime(), }) } return Directory{Root: root, RelativePath: cleanRelativePath, Entries: listedEntries}, nil } func cleanRelativePath(relativePath string) (string, error) { if relativePath == "" { return "", nil } path := filepath.FromSlash(relativePath) if filepath.IsAbs(path) { return "", ErrPathOutsideRoot } path = filepath.Clean(path) if path == ".." || strings.HasPrefix(path, ".."+string(filepath.Separator)) { return "", ErrPathOutsideRoot } if path == "." { return "", nil } return filepath.ToSlash(path), nil } func isWithin(root, candidate string) bool { relativePath, err := filepath.Rel(root, candidate) if err != nil { return false } return relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) }