diff --git a/chfs-ng/internal/api/server.go b/chfs-ng/internal/api/server.go index 8ad170e..152bad4 100644 --- a/chfs-ng/internal/api/server.go +++ b/chfs-ng/internal/api/server.go @@ -168,7 +168,9 @@ func (s *Server) files(w http.ResponseWriter, r *http.Request) { p := qpath(r) m := a.MaskFor(p) if !m.HasRead() { - http.Error(w, "forbidden", 403) + // CHFS keeps the files endpoint HTTP-successful for a denied directory + // and returns an empty listing with currentMask="". + writeJSON(w, map[string]any{"files": []fileInfo{}, "currentMask": "", "virtualRoot": false}) return } list, e := s.Store.List(p) @@ -179,6 +181,11 @@ func (s *Server) files(w http.ResponseWriter, r *http.Request) { out := make([]fileInfo, 0, len(list)) for _, fi := range list { fp := path.Join(p, fi.Name()) + // A rule.none entry is not merely read-less metadata: CHFS hides the + // entry from directory listings because the directory itself is denied. + if !a.MaskForEntry(fp).HasRead() { + continue + } sub := false if fi.IsDir() { if xs, e := s.Store.List(fp); e == nil { diff --git a/chfs-ng/internal/auth/rules_test.go b/chfs-ng/internal/auth/rules_test.go index 127ef30..ff039ca 100644 --- a/chfs-ng/internal/auth/rules_test.go +++ b/chfs-ng/internal/auth/rules_test.go @@ -9,6 +9,7 @@ func TestPathRuleSemantics(t *testing.T) { {Dir: "share", Mask: ParseMask("w"), ChildrenOnly: true}, {Dir: "drop", Mask: ParseMask("d"), ChildrenOnly: true}, {Dir: "pub*", Mask: ParseMask("r")}, + {Dir: "team/?", Mask: ParseMask("w")}, } cases := []struct{ path, want string }{ {"", "R"}, @@ -20,6 +21,42 @@ func TestPathRuleSemantics(t *testing.T) { {"drop/x.txt", "RWD"}, {"public/x.txt", "R"}, {"public/sub/x.txt", "R"}, + {"team/a", "RW"}, + {"team/ab", "R"}, + } + for _, tc := range cases { + if got := permStringForTest(a.MaskFor(tc.path)); got != tc.want { + t.Errorf("MaskFor(%q)=%q want %q", tc.path, got, tc.want) + } + } + entryCases := []struct{ path, want string }{ + {"private", ""}, + {"share", "R"}, + {"drop", "R"}, + {"public", "R"}, + {"team/a", "RW"}, + } + for _, tc := range entryCases { + if got := permStringForTest(a.MaskForEntry(tc.path)); got != tc.want { + t.Errorf("MaskForEntry(%q)=%q want %q", tc.path, got, tc.want) + } + } +} + +func TestMoreSpecificRuleWins(t *testing.T) { + a := &Account{Mask: ParseMask("r")} + a.Rules = []PathRule{ + {Dir: "share", Mask: ParseMask("w"), ChildrenOnly: true}, + {Dir: "share/private", Deny: true}, + {Dir: "share/public*", Mask: ParseMask("r")}, + } + cases := []struct{ path, want string }{ + {"share", "RW"}, + {"share/file.txt", "RW"}, + {"share/private", ""}, + {"share/private/file.txt", ""}, + {"share/public", "R"}, + {"share/public/file.txt", "R"}, } for _, tc := range cases { if got := permStringForTest(a.MaskFor(tc.path)); got != tc.want {