From 89c5ec064dd8b158a01dd1827e181ccde381e02f Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 28 Apr 2026 16:56:47 -0500 Subject: [PATCH] feat(zddc-server): hide _-prefixed entries from listings (e.g. _template) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- AGENTS.md | 2 +- zddc/README.md | 16 +++-- zddc/internal/handler/projectshandler.go | 8 ++- zddc/internal/handler/projectshandler_test.go | 68 +++++++++++++++++++ zddc/internal/listing/listing.go | 10 ++- zddc/internal/listing/listing_test.go | 60 ++++++++++++++++ 6 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 zddc/internal/handler/projectshandler_test.go create mode 100644 zddc/internal/listing/listing_test.go diff --git a/AGENTS.md b/AGENTS.md index 3a2bf70..5c10f03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -288,4 +288,4 @@ git push --tags - 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`) - `.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. diff --git a/zddc/README.md b/zddc/README.md index 8f3908a..9ee5c37 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -165,11 +165,17 @@ they are neither listed nor queryable. A direct request to a denied path returns ### Reserved hidden segments -Any path under `ZDDC_ROOT` whose URL contains a dot-prefixed segment (e.g. `/.devshell/`, -`/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 -through. This lets operators store side-state (caches, dev-shell home dirs, snapshot -staging) on the same volume that's served, without exposing it. +Two prefixes are filtered from listings under `ZDDC_ROOT`: + +- **`.`-prefixed** (e.g. `/.devshell/`, `/Project-A/.internal/notes.md`) — excluded + from listings **and** 404 on direct HTTP access. The recognized virtual prefixes + (`.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 diff --git a/zddc/internal/handler/projectshandler.go b/zddc/internal/handler/projectshandler.go index 3343c7e..0b43f4a 100644 --- a/zddc/internal/handler/projectshandler.go +++ b/zddc/internal/handler/projectshandler.go @@ -37,8 +37,12 @@ func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request) continue } name := entry.Name() - // Skip hidden directories - if strings.HasPrefix(name, ".") { + // Skip hidden directories. Both '.' and '_' are reserved prefixes: + // '.' 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 } absPath := filepath.Join(cfg.Root, name) diff --git a/zddc/internal/handler/projectshandler_test.go b/zddc/internal/handler/projectshandler_test.go new file mode 100644 index 0000000..af3d116 --- /dev/null +++ b/zddc/internal/handler/projectshandler_test.go @@ -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"]) + } + } +} diff --git a/zddc/internal/listing/listing.go b/zddc/internal/listing/listing.go index c435ff8..251dc83 100644 --- a/zddc/internal/listing/listing.go +++ b/zddc/internal/listing/listing.go @@ -7,14 +7,18 @@ import ( // FromDirEntries converts os.DirEntry slice to []FileInfo. // 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) { var result []FileInfo for _, entry := range entries { name := entry.Name() - // Skip hidden files and dotfiles - if len(name) == 0 || name[0] == '.' { + // Skip hidden entries. '.' and '_' are both reserved prefixes: + // '.' 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 } diff --git a/zddc/internal/listing/listing_test.go b/zddc/internal/listing/listing_test.go new file mode 100644 index 0000000..95e9a88 --- /dev/null +++ b/zddc/internal/listing/listing_test.go @@ -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) + } + } +}