diff --git a/zddc/internal/zddc/virtualviews.go b/zddc/internal/zddc/virtualviews.go new file mode 100644 index 0000000..141f8e2 --- /dev/null +++ b/zddc/internal/zddc/virtualviews.go @@ -0,0 +1,361 @@ +package zddc + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Virtual project-level table views — SSR, MDL rollup, RSK rollup. +// +// All three are declared `virtual: true` in defaults.zddc.yaml under +// `/{ssr,mdl,rsk}`. The folder does not exist on disk: the +// server synthesizes listings by walking archive/*/ at request time +// and rewrites file reads/writes back to canonical paths inside the +// per-party folders. ACL on each synthetic row is evaluated against +// the canonical `/archive//` chain, so party owners +// can edit their own rows and non-owners see them read-only. +// +// URL conventions +// +// //ssr/ → directory listing +// //ssr/table.yaml | form.yaml → embedded default spec +// //ssr/.yaml → reads /archive//ssr.yaml +// //ssr/.yaml.html → form edit (form recognizer) +// //ssr/form.html → "+ Add row" — SSR create +// +// //mdl/ → rollup listing +// //mdl/table.yaml | form.yaml → embedded default project-rollup spec +// //mdl/__.yaml → reads /archive//mdl/.yaml +// //mdl/__.yaml.html → form edit +// +// //rsk/ → analogous +// +// Modeled on virtualreceived.go: one resolver produces canonical +// paths; every caller (listing builder, file API rewrite, form +// recognizer) reads its policy chain from the canonical path. + +// VirtualViewKind classifies a resolved virtual URL. +type VirtualViewKind int + +const ( + VirtualViewNone VirtualViewKind = iota + VirtualViewSSRRoot + VirtualViewSSRSpec + VirtualViewSSRRow + VirtualViewMDLRoot + VirtualViewMDLSpec + VirtualViewMDLRow + VirtualViewRSKRoot + VirtualViewRSKSpec + VirtualViewRSKRow +) + +// IsRowKind reports whether k targets a per-party row file (true for +// SSRRow, MDLRow, RSKRow). +func (k VirtualViewKind) IsRowKind() bool { + switch k { + case VirtualViewSSRRow, VirtualViewMDLRow, VirtualViewRSKRow: + return true + } + return false +} + +// IsSpecKind reports whether k targets a virtual table.yaml/form.yaml. +func (k VirtualViewKind) IsSpecKind() bool { + switch k { + case VirtualViewSSRSpec, VirtualViewMDLSpec, VirtualViewRSKSpec: + return true + } + return false +} + +// IsRootKind reports whether k targets the listing-level URL of a +// virtual view. +func (k VirtualViewKind) IsRootKind() bool { + switch k { + case VirtualViewSSRRoot, VirtualViewMDLRoot, VirtualViewRSKRoot: + return true + } + return false +} + +// VirtualViewResolution captures the result of mapping a URL onto +// one of the project-level virtual table views. All fields are +// populated only when Resolved is true. +type VirtualViewResolution struct { + Resolved bool + Kind VirtualViewKind + + Project string // "" + ProjectURL string // "//" + ProjectAbs string // / + + Slot string // "ssr", "mdl", or "rsk" + SlotURL string // "///" + + // Populated for VirtualView*Spec kinds: "table.yaml" or "form.yaml". + SpecBase string + + // Populated for VirtualView*Row kinds. + Party string // party folder name (e.g. "0330C1") + PartyArchive string // //archive/ + CanonicalAbs string // underlying file on disk + CanonicalURL string // //archive//... + SchemaAbs string // SSR only — /ssr.form.yaml (may not exist; falls back to embedded) + RowFilename string // MDL/RSK rollups only — e.g. "D-001.yaml" +} + +// virtualViewRE matches //[/] where slot is one +// of the canonical virtual view names. Capture 1 = project, capture +// 2 = slot, capture 3 = rest (may be empty). +var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(ssr|mdl|rsk)(?:/(.*))?$`) + +// partyNameRE matches the SSR schema's `name` pattern. Same regex +// used at row-resolution time so URLs with invalid party tokens fail +// resolution cleanly instead of producing impossible canonical paths. +var partyNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]*$`) + +// ValidPartyName reports whether s is a valid party folder name — +// starts with [A-Za-z0-9], then any of [A-Za-z0-9.-]. Used by URL +// resolution AND by the SSR create handler to validate user input. +func ValidPartyName(s string) bool { + return partyNameRE.MatchString(s) +} + +// ResolveVirtualView inspects urlPath and returns a populated +// resolution iff the URL targets one of the project-level virtual +// views (ssr/, mdl/, rsk/). On a non-match, Resolved=false. +// +// The resolver does NOT check that the project / party / row file +// actually exist on disk — that's the caller's job (handlers use +// the canonical path; listings synthesize from real disk state). +// +// urlPath must be a server-relative URL with one leading slash. +// Trailing slashes are tolerated for root kinds. +func ResolveVirtualView(fsRoot, urlPath string) VirtualViewResolution { + var out VirtualViewResolution + if urlPath == "" || urlPath[0] != '/' { + return out + } + trimmed := strings.TrimSuffix(urlPath, "/") + m := virtualViewRE.FindStringSubmatch(trimmed) + if m == nil { + return out + } + project := m[1] + slot := m[2] + rest := m[3] + + if project == "" || strings.HasPrefix(project, ".") || strings.HasPrefix(project, "_") { + return out + } + projectAbs := filepath.Join(fsRoot, filepath.FromSlash(project)) + if !strings.HasPrefix(projectAbs, fsRoot+string(filepath.Separator)) && projectAbs != fsRoot { + return out + } + + out.Project = project + out.ProjectURL = "/" + project + "/" + out.ProjectAbs = projectAbs + out.Slot = slot + out.SlotURL = "/" + project + "/" + slot + "/" + + if rest == "" { + switch slot { + case "ssr": + out.Kind = VirtualViewSSRRoot + case "mdl": + out.Kind = VirtualViewMDLRoot + case "rsk": + out.Kind = VirtualViewRSKRoot + } + out.Resolved = true + return out + } + + if rest == "table.yaml" || rest == "form.yaml" { + switch slot { + case "ssr": + out.Kind = VirtualViewSSRSpec + case "mdl": + out.Kind = VirtualViewMDLSpec + case "rsk": + out.Kind = VirtualViewRSKSpec + } + out.SpecBase = rest + out.Resolved = true + return out + } + + // Row files — must be a single segment ending in .yaml. + if strings.Contains(rest, "/") || !strings.HasSuffix(rest, ".yaml") { + return out + } + name := strings.TrimSuffix(rest, ".yaml") + + if slot == "ssr" { + if !ValidPartyName(name) { + return out + } + out.Party = name + out.PartyArchive = filepath.Join(projectAbs, "archive", name) + out.CanonicalAbs = filepath.Join(out.PartyArchive, "ssr.yaml") + out.CanonicalURL = "/" + project + "/archive/" + name + "/ssr.yaml" + out.SchemaAbs = filepath.Join(out.PartyArchive, "ssr.form.yaml") + out.Kind = VirtualViewSSRRow + out.Resolved = true + return out + } + + // MDL/RSK rollup row — __.yaml. + idx := strings.Index(name, "__") + if idx <= 0 || idx >= len(name)-2 { + return out + } + party := name[:idx] + rowBase := name[idx+2:] + if !ValidPartyName(party) || rowBase == "" || strings.Contains(rowBase, "__") { + return out + } + out.Party = party + out.PartyArchive = filepath.Join(projectAbs, "archive", party) + out.RowFilename = rowBase + ".yaml" + out.CanonicalAbs = filepath.Join(out.PartyArchive, slot, out.RowFilename) + out.CanonicalURL = "/" + project + "/archive/" + party + "/" + slot + "/" + out.RowFilename + switch slot { + case "mdl": + out.Kind = VirtualViewMDLRow + case "rsk": + out.Kind = VirtualViewRSKRow + } + out.Resolved = true + return out +} + +// IsSSRCreateURL reports whether urlPath is //ssr/form.html +// — the SSR "+ Add row" target. Returns the project name when matched. +func IsSSRCreateURL(urlPath string) (string, bool) { + if urlPath == "" || urlPath[0] != '/' { + return "", false + } + parts := strings.Split(strings.TrimPrefix(urlPath, "/"), "/") + if len(parts) != 3 || parts[1] != "ssr" || parts[2] != "form.html" { + return "", false + } + project := parts[0] + if project == "" || strings.HasPrefix(project, ".") || strings.HasPrefix(project, "_") { + return "", false + } + return project, true +} + +// StripYAMLHTML returns urlPath with a trailing ".html" stripped iff +// the URL has the form-edit shape ".../.yaml.html". Otherwise +// returns urlPath unchanged + false. The form recognizer calls this +// before passing the data URL into ResolveVirtualView. +func StripYAMLHTML(urlPath string) (string, bool) { + if strings.HasSuffix(urlPath, ".yaml.html") { + return strings.TrimSuffix(urlPath, ".html"), true + } + return urlPath, false +} + +// ListSSRParties returns the party folder names that exist under +// /archive/. Names are filtered through ValidPartyName so a +// hand-created folder with a weird name (e.g. "0330C1 (draft)") won't +// confuse the rest of the resolver. Returns nil + nil when archive/ +// doesn't exist on disk. +func ListSSRParties(fsRoot, projectAbs string) ([]string, error) { + archive := filepath.Join(projectAbs, "archive") + entries, err := os.ReadDir(archive) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + out := make([]string, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if !ValidPartyName(name) { + continue + } + out = append(out, name) + } + sort.Strings(out) + return out, nil +} + +// VirtualRollupRow describes one synthetic row in a project-level +// MDL or RSK rollup. +type VirtualRollupRow struct { + Party string // source party folder + Filename string // e.g. "D-001.yaml" + SyntheticName string // e.g. "0330C1__D-001.yaml" — used in URLs + CanonicalAbs string // underlying file on disk +} + +// ListRollupRows walks /archive/*// and returns one +// synthetic row per *.yaml file. slot must be "mdl" or "rsk". +// Returns rows sorted by (party, filename). +// +// Skipped: +// - filenames containing "__" (would break the party__file split) +// - "table.yaml" and "form.yaml" (operator spec/schema, not rows) +// - any non-*.yaml file +// - parties with invalid folder names (filtered by ListSSRParties) +func ListRollupRows(fsRoot, projectAbs, slot string) ([]VirtualRollupRow, error) { + if slot != "mdl" && slot != "rsk" { + return nil, errors.New("ListRollupRows: slot must be mdl or rsk") + } + parties, err := ListSSRParties(fsRoot, projectAbs) + if err != nil { + return nil, err + } + out := make([]VirtualRollupRow, 0, len(parties)) + for _, party := range parties { + slotDir := filepath.Join(projectAbs, "archive", party, slot) + entries, err := os.ReadDir(slotDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return nil, err + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, ".yaml") { + continue + } + if name == "table.yaml" || name == "form.yaml" { + continue + } + if strings.Contains(name, "__") { + continue + } + out = append(out, VirtualRollupRow{ + Party: party, + Filename: name, + SyntheticName: party + "__" + name, + CanonicalAbs: filepath.Join(slotDir, name), + }) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Party != out[j].Party { + return out[i].Party < out[j].Party + } + return out[i].Filename < out[j].Filename + }) + return out, nil +} diff --git a/zddc/internal/zddc/virtualviews_test.go b/zddc/internal/zddc/virtualviews_test.go new file mode 100644 index 0000000..989df3d --- /dev/null +++ b/zddc/internal/zddc/virtualviews_test.go @@ -0,0 +1,288 @@ +package zddc + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveVirtualView_Roots(t *testing.T) { + root := t.TempDir() + cases := []struct { + url string + want VirtualViewKind + }{ + {"/Project/ssr", VirtualViewSSRRoot}, + {"/Project/ssr/", VirtualViewSSRRoot}, + {"/Project/mdl", VirtualViewMDLRoot}, + {"/Project/mdl/", VirtualViewMDLRoot}, + {"/Project/rsk", VirtualViewRSKRoot}, + {"/Project/rsk/", VirtualViewRSKRoot}, + } + for _, tc := range cases { + got := ResolveVirtualView(root, tc.url) + if !got.Resolved || got.Kind != tc.want { + t.Errorf("%s: kind=%d resolved=%v; want kind=%d resolved=true", tc.url, got.Kind, got.Resolved, tc.want) + } + if got.Project != "Project" { + t.Errorf("%s: project=%q want Project", tc.url, got.Project) + } + if !got.Kind.IsRootKind() { + t.Errorf("%s: IsRootKind=false", tc.url) + } + } +} + +func TestResolveVirtualView_Specs(t *testing.T) { + root := t.TempDir() + cases := []struct { + url string + wantKind VirtualViewKind + wantBase string + }{ + {"/Project/ssr/table.yaml", VirtualViewSSRSpec, "table.yaml"}, + {"/Project/ssr/form.yaml", VirtualViewSSRSpec, "form.yaml"}, + {"/Project/mdl/table.yaml", VirtualViewMDLSpec, "table.yaml"}, + {"/Project/mdl/form.yaml", VirtualViewMDLSpec, "form.yaml"}, + {"/Project/rsk/table.yaml", VirtualViewRSKSpec, "table.yaml"}, + {"/Project/rsk/form.yaml", VirtualViewRSKSpec, "form.yaml"}, + } + for _, tc := range cases { + got := ResolveVirtualView(root, tc.url) + if !got.Resolved || got.Kind != tc.wantKind { + t.Errorf("%s: kind=%d resolved=%v; want kind=%d", tc.url, got.Kind, got.Resolved, tc.wantKind) + } + if got.SpecBase != tc.wantBase { + t.Errorf("%s: SpecBase=%q want %q", tc.url, got.SpecBase, tc.wantBase) + } + if !got.Kind.IsSpecKind() { + t.Errorf("%s: IsSpecKind=false", tc.url) + } + } +} + +func TestResolveVirtualView_SSRRow(t *testing.T) { + root := t.TempDir() + got := ResolveVirtualView(root, "/Project/ssr/0330C1.yaml") + if !got.Resolved || got.Kind != VirtualViewSSRRow { + t.Fatalf("unexpected resolution: %+v", got) + } + if got.Party != "0330C1" { + t.Errorf("Party=%q want 0330C1", got.Party) + } + wantAbs := filepath.Join(root, "Project", "archive", "0330C1", "ssr.yaml") + if got.CanonicalAbs != wantAbs { + t.Errorf("CanonicalAbs=%q want %q", got.CanonicalAbs, wantAbs) + } + wantSchema := filepath.Join(root, "Project", "archive", "0330C1", "ssr.form.yaml") + if got.SchemaAbs != wantSchema { + t.Errorf("SchemaAbs=%q want %q", got.SchemaAbs, wantSchema) + } + if !got.Kind.IsRowKind() { + t.Errorf("IsRowKind=false") + } +} + +func TestResolveVirtualView_RollupRow(t *testing.T) { + root := t.TempDir() + cases := []struct { + url string + wantKind VirtualViewKind + wantParty string + wantFilename string + wantSlot string + }{ + {"/Project/mdl/0330C1__D-001.yaml", VirtualViewMDLRow, "0330C1", "D-001.yaml", "mdl"}, + {"/Project/rsk/Acme__R-005.yaml", VirtualViewRSKRow, "Acme", "R-005.yaml", "rsk"}, + } + for _, tc := range cases { + got := ResolveVirtualView(root, tc.url) + if !got.Resolved || got.Kind != tc.wantKind { + t.Errorf("%s: kind=%d resolved=%v; want kind=%d", tc.url, got.Kind, got.Resolved, tc.wantKind) + continue + } + if got.Party != tc.wantParty { + t.Errorf("%s: Party=%q want %q", tc.url, got.Party, tc.wantParty) + } + if got.RowFilename != tc.wantFilename { + t.Errorf("%s: RowFilename=%q want %q", tc.url, got.RowFilename, tc.wantFilename) + } + wantAbs := filepath.Join(root, "Project", "archive", tc.wantParty, tc.wantSlot, tc.wantFilename) + if got.CanonicalAbs != wantAbs { + t.Errorf("%s: CanonicalAbs=%q want %q", tc.url, got.CanonicalAbs, wantAbs) + } + } +} + +func TestResolveVirtualView_NonMatches(t *testing.T) { + root := t.TempDir() + cases := []string{ + "/", + "/Project", + "/Project/", + "/Project/working", + "/Project/archive/Acme/mdl", + "/Project/ssr/invalid__name__double.yaml", // double-double underscore is rejected + "/Project/mdl/__leading.yaml", // empty party + "/Project/mdl/party__.yaml", // empty rowBase + "/Project/ssr/.hidden.yaml", // dotfile party name + "/Project/ssr/0330C1.yaml/sub", // sub-path under row file + "/Project/notaslot/table.yaml", + } + for _, url := range cases { + got := ResolveVirtualView(root, url) + if got.Resolved { + t.Errorf("%s: unexpectedly resolved as kind %d", url, got.Kind) + } + } +} + +func TestIsSSRCreateURL(t *testing.T) { + cases := []struct { + url string + want string + wantOK bool + }{ + {"/Project/ssr/form.html", "Project", true}, + {"/Other-Project/ssr/form.html", "Other-Project", true}, + {"/Project/ssr/", "", false}, + {"/Project/ssr/Acme.yaml.html", "", false}, + {"/Project/mdl/form.html", "", false}, + {"/.hidden/ssr/form.html", "", false}, + } + for _, tc := range cases { + got, ok := IsSSRCreateURL(tc.url) + if ok != tc.wantOK { + t.Errorf("%s: ok=%v want %v", tc.url, ok, tc.wantOK) + } + if got != tc.want { + t.Errorf("%s: project=%q want %q", tc.url, got, tc.want) + } + } +} + +func TestStripYAMLHTML(t *testing.T) { + cases := []struct { + in string + want string + wantOK bool + }{ + {"/Project/ssr/Acme.yaml.html", "/Project/ssr/Acme.yaml", true}, + {"/Project/mdl/foo__bar.yaml.html", "/Project/mdl/foo__bar.yaml", true}, + {"/Project/ssr/Acme.yaml", "/Project/ssr/Acme.yaml", false}, + {"/Project/ssr/form.html", "/Project/ssr/form.html", false}, + } + for _, tc := range cases { + got, ok := StripYAMLHTML(tc.in) + if got != tc.want || ok != tc.wantOK { + t.Errorf("%s: (%q, %v) want (%q, %v)", tc.in, got, ok, tc.want, tc.wantOK) + } + } +} + +func TestValidPartyName(t *testing.T) { + ok := []string{"0330C1", "Acme", "Acme.Inc", "Acme-Subsidiary", "a", "0", "0330C1.draft", "X-Y-Z"} + bad := []string{"", ".hidden", "_underscore", "Acme/sub", "Acme Inc", "Acme(Inc)", "Acme,Inc", "..", "-leading-dash"} + for _, s := range ok { + if !ValidPartyName(s) { + t.Errorf("ValidPartyName(%q) = false, want true", s) + } + } + for _, s := range bad { + if ValidPartyName(s) { + t.Errorf("ValidPartyName(%q) = true, want false", s) + } + } +} + +func TestListSSRParties(t *testing.T) { + root := t.TempDir() + projectAbs := filepath.Join(root, "Project") + for _, party := range []string{"0330C1", "0440P2", "Acme"} { + if err := os.MkdirAll(filepath.Join(projectAbs, "archive", party), 0o755); err != nil { + t.Fatal(err) + } + } + // A file (not a dir) and a hidden folder should be filtered out. + if err := os.WriteFile(filepath.Join(projectAbs, "archive", "stray.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(projectAbs, "archive", ".hidden"), 0o755); err != nil { + t.Fatal(err) + } + + parties, err := ListSSRParties(root, projectAbs) + if err != nil { + t.Fatal(err) + } + want := []string{"0330C1", "0440P2", "Acme"} + if strings.Join(parties, ",") != strings.Join(want, ",") { + t.Errorf("got %v, want %v", parties, want) + } +} + +func TestListSSRParties_NoArchive(t *testing.T) { + root := t.TempDir() + projectAbs := filepath.Join(root, "Project") + parties, err := ListSSRParties(root, projectAbs) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if len(parties) != 0 { + t.Errorf("got %v, want empty", parties) + } +} + +func TestListRollupRows(t *testing.T) { + root := t.TempDir() + projectAbs := filepath.Join(root, "Project") + + for _, party := range []string{"0330C1", "0440P2"} { + mdlDir := filepath.Join(projectAbs, "archive", party, "mdl") + if err := os.MkdirAll(mdlDir, 0o755); err != nil { + t.Fatal(err) + } + } + // Real rows. + if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "D-001.yaml"), []byte("id: D-001\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "D-002.yaml"), []byte("id: D-002\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0440P2", "mdl", "D-010.yaml"), []byte("id: D-010\n"), 0o644); err != nil { + t.Fatal(err) + } + // Skipped: table.yaml, form.yaml, anything containing "__". + if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "table.yaml"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "form.yaml"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "weird__name.yaml"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + rows, err := ListRollupRows(root, projectAbs, "mdl") + if err != nil { + t.Fatal(err) + } + if len(rows) != 3 { + t.Fatalf("got %d rows, want 3; rows=%+v", len(rows), rows) + } + wantNames := []string{"0330C1__D-001.yaml", "0330C1__D-002.yaml", "0440P2__D-010.yaml"} + for i, want := range wantNames { + if rows[i].SyntheticName != want { + t.Errorf("row[%d].SyntheticName=%q want %q", i, rows[i].SyntheticName, want) + } + } +} + +func TestListRollupRows_BadSlot(t *testing.T) { + root := t.TempDir() + if _, err := ListRollupRows(root, root, "ssr"); err == nil { + t.Error("expected error for slot=ssr (only mdl/rsk valid)") + } +}