Adds a UI checkbox next to the existing Sort dropdown that surfaces
hidden entries when ACL would otherwise allow read. Default off
(matches today's filtered behavior). On toggle, browse re-fetches
the current directory with ?hidden=1 and re-renders.
┌─ browse toolbar ─────────────────────────────────────────────┐
│ Sort: [Name (A→Z) ▾] ☐ Show hidden │
└──────────────────────────────────────────────────────────────┘
Server-side surface:
- internal/fs/tree.go ListDirectory gains an `includeHidden bool`
parameter. The .-prefix filter (previously hard-coded) now also
drops _-prefix entries (matches dispatch's reserved-prefix guard)
and honors the new flag.
- internal/handler/directory.go reads `?hidden=1` from the request
and threads it through.
- cmd/zddc-server/main.go dispatcher relaxes its dot-prefix and
_-prefix guards for GET/HEAD when `?hidden=1` is set, so clicking
a hidden entry's link works. `_app/` (apps cache) stays
unconditionally reserved — those bytes must go through the apps
resolver. Writes to hidden paths stay blocked (the file API has
its own segment check that the flag does NOT relax).
- internal/listing/listing.go: signature parity (the lower-level
helper that's used by tests + non-cascade listing paths).
Security model unchanged: the ACL chain on the parent dir is the only
real gate. Whoever can read the dir can see its contents — toggling
"Show hidden" just stops the client-side filter from masking
.-prefixed and _-prefixed entries. Hidden paths today:
• <dir>/.zddc ACL YAML — already exposed via /.profile/zddc
• <dir>/.converted/<base> cached MD→DOCX/HTML/PDF, same sensitivity as source
• <root>/.zddc.d/tokens/ per-token metadata; filename = sha256(token)
so not bearer-usable. Default root ACL
restricts to admins; matches /.tokens UI.
• <root>/.zddc.d/logs/ access logs; same admins-only audience
• <root>/_app/ cached upstream tool HTML (public)
• <root>/_template/ install.zip scaffolding (public)
None of these contain bearer credentials or secret material that the
existing ACL doesn't already gate. The walls are still the cascade.
206 lines
6.3 KiB
Go
206 lines
6.3 KiB
Go
package fs
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
func setupTreeRoot(t *testing.T) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
// Permissive root .zddc so subdirectory ACL checks pass.
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
return root
|
|
}
|
|
|
|
func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "working"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
|
|
var virtual *string
|
|
for i := range got {
|
|
if got[i].Virtual {
|
|
n := got[i].Name
|
|
virtual = &n
|
|
}
|
|
}
|
|
if virtual == nil {
|
|
t.Fatalf("expected synthetic <viewer-email>/ entry, got entries: %+v", got)
|
|
}
|
|
if *virtual != "alice@example.com/" {
|
|
t.Errorf("synthetic name = %q, want alice@example.com/", *virtual)
|
|
}
|
|
}
|
|
|
|
func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
// A real folder exists for the viewer (any case).
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "Alice@Example.com"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
for _, fi := range got {
|
|
if fi.Virtual {
|
|
t.Errorf("synthetic entry should be suppressed when a case-fold match exists; got %+v", fi)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "working"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
for _, fi := range got {
|
|
if fi.Virtual {
|
|
t.Errorf("anonymous viewer should not see synthetic entries; got %+v", fi)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "staging"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
for _, fi := range got {
|
|
if fi.Virtual {
|
|
t.Errorf("staging/ should not have a synthetic user-home entry; got %+v", fi)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
// Listing inside working/ at depth 3+ — no synthetic entry should fire.
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "alice@example.com"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/working/alice@example.com", "alice@example.com",
|
|
"/Proj/working/alice@example.com/", false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
for _, fi := range got {
|
|
if fi.Virtual {
|
|
t.Errorf("nested working/ subdir must not synthesise the user home; got %+v", fi)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
// Pre-existing PascalCase Working/.
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj", "Working"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
var found bool
|
|
for _, fi := range got {
|
|
if fi.Virtual {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("PascalCase Working/ should still surface the synthetic entry; got entries: %+v", got)
|
|
}
|
|
}
|
|
|
|
// Listing a canonical project folder that doesn't exist on disk yet
|
|
// returns an empty slice instead of os.ErrNotExist. The stage-strip
|
|
// nav links into <project>/working/ etc. unconditionally; this keeps
|
|
// fresh projects (no working/ on disk yet) from 404'ing.
|
|
func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
// Proj exists but Proj/working/ does NOT.
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
for _, stage := range []string{"working", "staging", "reviewing", "archive"} {
|
|
got, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false)
|
|
if err != nil {
|
|
t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, err)
|
|
continue
|
|
}
|
|
// working/ surfaces a synthetic <viewer-email>/ entry; the others
|
|
// should be a flat empty listing.
|
|
if stage == "working" {
|
|
if len(got) != 1 || !got[0].Virtual {
|
|
t.Errorf("ListDirectory(Proj/working) on missing dir: got %+v, want only the virtual home entry", got)
|
|
}
|
|
} else {
|
|
if len(got) != 0 {
|
|
t.Errorf("ListDirectory(Proj/%s) on missing dir: got %+v, want empty", stage, got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Non-canonical paths still 404 (return os.ErrNotExist) — the fallback
|
|
// only applies to the four canonical project-root folders.
|
|
func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
|
|
root := setupTreeRoot(t)
|
|
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
_, err := ListDirectory(context.Background(), nil, root,
|
|
"Proj/random-folder-that-doesnt-exist", "alice@example.com",
|
|
"/Proj/random-folder-that-doesnt-exist/", false)
|
|
if !os.IsNotExist(err) {
|
|
t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err)
|
|
}
|
|
}
|