feat(zddc-server): hide _-prefixed entries from listings (e.g. _template)
Listings now filter both '.' and '_' prefixes: - '.' entries: excluded from listings AND 404 on direct HTTP access (existing behavior). For invisible side-state like .devshell. - '_' entries: excluded from listings only — direct URL access still works. For operator scaffolding like install.zip's _template/ directory of bootstrap stubs that should be reachable but should not appear in the project picker. Filter applied at both listing entry points: ServeProjectList (the project picker JSON at GET / Accept: application/json) and the generic listing/FromDirEntries (used by ServeDirectory for sub-directory browse listings). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9ef90800b1
commit
89c5ec064d
6 changed files with 153 additions and 11 deletions
|
|
@ -288,4 +288,4 @@ git push --tags
|
||||||
- The `.archive` virtual path resolves ZDDC tracking numbers to their earliest-received revision
|
- The `.archive` virtual path resolves ZDDC tracking numbers to their earliest-received revision
|
||||||
- ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`)
|
- ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`)
|
||||||
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
|
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
|
||||||
- **Reserved hidden URL segments**: any path under `ZDDC_ROOT` whose URL contains a dot-prefixed segment (e.g. `/.devshell/coder/...`) returns 404 on direct HTTP access and is excluded from listings. Only `.archive` (virtual archive index) and `.admin` (debug page) are exempt. Lets operators co-locate side-state (caches, dev-shell home dirs) with served data on the same PVC.
|
- **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like install.zip's `_template/` that's still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable.
|
||||||
|
|
|
||||||
|
|
@ -165,11 +165,17 @@ they are neither listed nor queryable. A direct request to a denied path returns
|
||||||
|
|
||||||
### Reserved hidden segments
|
### Reserved hidden segments
|
||||||
|
|
||||||
Any path under `ZDDC_ROOT` whose URL contains a dot-prefixed segment (e.g. `/.devshell/`,
|
Two prefixes are filtered from listings under `ZDDC_ROOT`:
|
||||||
`/Project-A/.internal/notes.md`) is **404** on direct HTTP access and is excluded from
|
|
||||||
listings. The recognized virtual prefixes (`.archive`, `.admin`) are explicitly permitted
|
- **`.`-prefixed** (e.g. `/.devshell/`, `/Project-A/.internal/notes.md`) — excluded
|
||||||
through. This lets operators store side-state (caches, dev-shell home dirs, snapshot
|
from listings **and** 404 on direct HTTP access. The recognized virtual prefixes
|
||||||
staging) on the same volume that's served, without exposing it.
|
(`.archive`, `.admin`) are explicitly permitted through. This lets operators store
|
||||||
|
side-state (caches, dev-shell home dirs, snapshot staging) on the same volume
|
||||||
|
that's served, without exposing it.
|
||||||
|
- **`_`-prefixed** (e.g. `/_template/`) — excluded from listings only. Direct URL
|
||||||
|
access still works, so install.zip's `_template/` directory of bootstrap stubs
|
||||||
|
is reachable but doesn't clutter the project picker. Use this for operator-
|
||||||
|
managed scaffolding the user shouldn't browse to but might link to.
|
||||||
|
|
||||||
## Admin Debug Page
|
## Admin Debug Page
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,12 @@ func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
// Skip hidden directories
|
// Skip hidden directories. Both '.' and '_' are reserved prefixes:
|
||||||
if strings.HasPrefix(name, ".") {
|
// '.' for system/internal state (matches the listing-pipeline filter
|
||||||
|
// and the dispatch dot-prefix guard); '_' for operator-managed
|
||||||
|
// scaffolding like install.zip's _template/ directory that should
|
||||||
|
// be reachable by direct URL but not appear in the project picker.
|
||||||
|
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
absPath := filepath.Join(cfg.Root, name)
|
absPath := filepath.Join(cfg.Root, name)
|
||||||
|
|
|
||||||
68
zddc/internal/handler/projectshandler_test.go
Normal file
68
zddc/internal/handler/projectshandler_test.go
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestServeProjectListFiltersHiddenAndScaffolding asserts the project list
|
||||||
|
// excludes both '.'-prefixed entries (the long-standing rule, e.g. .devshell)
|
||||||
|
// AND '_'-prefixed entries (operator scaffolding like install.zip's
|
||||||
|
// _template/ that's reachable by direct URL but should not clutter the
|
||||||
|
// project picker).
|
||||||
|
func TestServeProjectListFiltersHiddenAndScaffolding(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
for _, name := range []string{
|
||||||
|
"Project-A",
|
||||||
|
"Project-B",
|
||||||
|
".devshell", // dot-prefixed dir — must be excluded
|
||||||
|
"_template", // underscore scaffolding — must be excluded
|
||||||
|
"_archive",
|
||||||
|
} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A loose .zddc that allows everyone, so ACL doesn't interfere with
|
||||||
|
// what we're actually testing (the prefix filter).
|
||||||
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
||||||
|
[]byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write .zddc: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ServeProjectList(cfg, rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var got []map[string]string
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]bool{"Project-A": true, "Project-B": true}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %d projects (%+v), want %d (%v)", len(got), got, len(want), want)
|
||||||
|
}
|
||||||
|
for _, p := range got {
|
||||||
|
if !want[p["name"]] {
|
||||||
|
t.Errorf("unexpected project in list: %q", p["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,14 +7,18 @@ import (
|
||||||
|
|
||||||
// FromDirEntries converts os.DirEntry slice to []FileInfo.
|
// FromDirEntries converts os.DirEntry slice to []FileInfo.
|
||||||
// baseURL is the URL prefix for this directory (must end with "/").
|
// baseURL is the URL prefix for this directory (must end with "/").
|
||||||
// Hidden files (starting with ".") are excluded.
|
// Entries starting with "." or "_" are excluded (see filter below).
|
||||||
func FromDirEntries(entries []os.DirEntry, baseURL string) ([]FileInfo, error) {
|
func FromDirEntries(entries []os.DirEntry, baseURL string) ([]FileInfo, error) {
|
||||||
var result []FileInfo
|
var result []FileInfo
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
|
|
||||||
// Skip hidden files and dotfiles
|
// Skip hidden entries. '.' and '_' are both reserved prefixes:
|
||||||
if len(name) == 0 || name[0] == '.' {
|
// '.' marks system/internal state (.zddc files, .archive virtual
|
||||||
|
// path, .admin debug page, dev-shell home dirs); '_' marks operator
|
||||||
|
// scaffolding like install.zip's _template/ directory that's
|
||||||
|
// reachable by direct URL but should not appear in browse listings.
|
||||||
|
if len(name) == 0 || name[0] == '.' || name[0] == '_' {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
60
zddc/internal/listing/listing_test.go
Normal file
60
zddc/internal/listing/listing_test.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
package listing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFromDirEntriesFiltersHidden asserts that both '.' and '_' prefixed
|
||||||
|
// entries are excluded from listings — the '.' branch is the long-standing
|
||||||
|
// rule (matches dispatch dot-prefix guard); '_' is for operator-managed
|
||||||
|
// scaffolding like install.zip's _template/ that should be reachable by
|
||||||
|
// direct URL but invisible to browse.
|
||||||
|
func TestFromDirEntriesFiltersHidden(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
for _, name := range []string{
|
||||||
|
"Project-A",
|
||||||
|
"Project-B",
|
||||||
|
".zddc", // hidden file
|
||||||
|
".devshell", // hidden dir
|
||||||
|
"_template", // scaffolding dir
|
||||||
|
"_archive", // scaffolding dir
|
||||||
|
"_notes.txt", // scaffolding file
|
||||||
|
"normal.txt",
|
||||||
|
} {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := FromDirEntries(entries, "/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FromDirEntries: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]bool{
|
||||||
|
"Project-A": true,
|
||||||
|
"Project-B": true,
|
||||||
|
"normal.txt": true,
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
var names []string
|
||||||
|
for _, e := range got {
|
||||||
|
names = append(names, e.Name)
|
||||||
|
}
|
||||||
|
t.Fatalf("got %d entries (%v), want %d (%v)", len(got), names, len(want), want)
|
||||||
|
}
|
||||||
|
for _, e := range got {
|
||||||
|
if !want[e.Name] {
|
||||||
|
t.Errorf("unexpected entry in listing: %q", e.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue