Files
orm/db/namespace_canonical_test.go
zeekayandHanzo Dev d0eabb4444 db: fold namespace case, and document the seam hanzoai/namespace takes
canonical exists to stop one file being opened under several keys -- its own
end-to-end test is called TestAliasesShareOneDatabase -- and it misses the one
alias where the two spellings really are one FILE rather than two. On a
case-insensitive filesystem, which is every macOS dev box, "org/Acme" and
"org/acme" are two map keys, two open handles and two hanzoai/replicate streams
writing one LTX history, and it all works locally. path.Clean cannot see that;
folding can. Folded, a namespace is the same namespace on every machine instead
of one file on the laptop and two in the cluster.

Consequence, stated because it is otherwise silent: a file already on disk under
a mixed-case name is now addressed by its lowercase name and has to be renamed.
Nothing in the fleet mints one -- commerce, the only live Namespaces consumer,
builds "user/<id>" and "org/<id>" from generated ids.

isLetter goes with it. After folding, its A-Z arm is unreachable, so the letter
set is stated once, inline, where the rule is.

The rest is preparation for hanzoai/namespace, which takes over what a namespace
IS and leaves the lifecycle -- resolve, open, cache, evict -- here.

  - A doc note on Namespace saying exactly what moves and what stays, so the
    seam is in the code rather than in someone's head. The entry that is not
    obvious is the empty name: canonical is the only thing that rejects it, and
    pathFor("/cache", "") returns "/cache/.db" with no error. That is CONTAINED,
    and therefore one database silently shared by everyone who forgot to set a
    namespace -- worse than an escape, and pathFor has to take it over when
    canonical leaves.
  - string(ns) -> ns.String() at the three sites that survive the retype
    (pathFor, OpenNamespace, replicated.urlFor). The only conversion left is
    inside canonical, which is the function that goes.

Also gofmt the var block WithAll added, and "orm/db:" -> "db:" on its MaxOpen
error, the one error in the package that spelled the prefix differently.

go build ./... && go vet ./... && go test -race ./db/... pass, as does the
replicated module.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 09:45:55 -07:00

113 lines
3.4 KiB
Go

package db
import (
"context"
"os"
"path/filepath"
"testing"
)
// A namespace names one database. These prove the two ways that can go wrong —
// naming a file outside the cache, and naming one file under several keys — are
// closed at the single door every caller goes through.
func TestNamespaceMustBeginWithALetter(t *testing.T) {
// Each of these either points outside the cache or is a second spelling of
// a name that does. Rejecting them is one rule, applied after cleaning, so
// a name that only escapes once its dot-segments resolve is rejected too.
for _, n := range []Namespace{
"",
"/",
"/org/acme",
"//org/acme",
"../org/acme",
"..",
".",
"a/../../b",
"-org/acme",
".hidden",
"\\org\\acme",
} {
if got, err := canonical(n); err == nil {
t.Errorf("canonical(%q) = %q, want an error", n, got)
}
}
}
func TestNamespaceHasOneSpelling(t *testing.T) {
// Many strings, one file. Keyed raw each opens its own entry over one
// history; canonical collapses them to the name the file is stored under.
for _, tc := range []struct{ in, want Namespace }{
{"org/acme", "org/acme"},
{"org//acme", "org/acme"},
{"org/acme/", "org/acme"},
{"org/./acme", "org/acme"},
{"org/x/../acme", "org/acme"},
{"./org/acme", "org/acme"},
// Case is the alias where both spellings are one FILE on a
// case-insensitive filesystem, so it is the one that corrupts rather
// than merely duplicates.
{"Org/Acme", "org/acme"},
{"ORG/ACME", "org/acme"},
{"user/z", "user/z"},
{"org/acme/project/atlas", "org/acme/project/atlas"},
} {
got, err := canonical(tc.in)
if err != nil {
t.Fatalf("canonical(%q): %v", tc.in, err)
}
if got != tc.want {
t.Errorf("canonical(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// TestAliasesShareOneDatabase is the invariant the canonical key exists for: a
// write through one spelling is readable through another, because both resolve
// to a single open handle rather than two racing over one file.
func TestAliasesShareOneDatabase(t *testing.T) {
dir := t.TempDir()
r, err := NewNamespaces(NamespacesConfig[DB]{Dir: dir, MaxOpen: 4, Open: OpenNamespace})
if err != nil {
t.Fatal(err)
}
defer r.Close()
ctx := context.Background()
open := func(n Namespace) error {
return r.With(ctx, n, func(DB) error { return nil })
}
for _, n := range []Namespace{"org/acme", "org//acme", "org/acme/", "org/./acme", "Org/Acme"} {
if err := open(n); err != nil {
t.Fatalf("%q: %v", n, err)
}
}
if n := r.Held(); n != 1 {
t.Errorf("open handles = %d, want 1 — aliases opened separate entries", n)
}
// One file on disk, under the canonical name.
if _, err := os.Stat(filepath.Join(dir, "org", "acme.db")); err != nil {
t.Errorf("canonical file missing: %v", err)
}
}
func TestEscapingNamespaceWritesNothing(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(dir, "outside.db")
r, err := NewNamespaces(NamespacesConfig[DB]{Dir: filepath.Join(dir, "cache"), MaxOpen: 2, Open: OpenNamespace})
if err != nil {
t.Fatal(err)
}
defer r.Close()
for _, n := range []Namespace{"../outside", "/etc/passwd", "a/../../outside"} {
if err := r.With(context.Background(), n, func(DB) error { return nil }); err == nil {
t.Errorf("With(%q) succeeded, want refusal", n)
}
}
if _, err := os.Stat(outside); err == nil {
t.Error("a refused namespace still created a file outside the cache")
}
}