A namespace naming a database, and that name becoming a location

Naming the database and rendering that name as a place were one primitive
living in two repositories: hanzoai/cloud held the slugger, the layout and
the resolver, and this package held only the value they produced. A fact with
two homes is a fact that can disagree with itself, and these particular strings
are directory names on live volumes and keys in live buckets — a disagreement
does not fail, it opens an empty database beside a real one.

So the whole primitive moves here and cloud imports it:

  Sanitize     one injective slug for a name a person chose
  OrgProject   the door those names walk through to become a namespace
  MustOrgProject
  Key          a namespace rendered as the place one subsystem's file lives
  Path         Key resolved against a data directory

Sanitize refuses a name carrying an invisible rune (nothing downstream can be
trusted not to trim it) and disambiguates every other fold with a hash of the
raw bytes, so two spellings are two files. OrgProject is named for what it
takes; Of already means something else here — the namespace a billing subject
owns — and clobbering it would have been the same collapse in miniature.

Every golden value in the tests was captured from the implementation that
wrote the files already on disk, before the code moved, so the rendering is
byte-identical and no live data is orphaned.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-01 18:14:05 -07:00
co-authored by Hanzo Dev
parent f16f998889
commit 555c41b82e
7 changed files with 706 additions and 0 deletions
+21
View File
@@ -23,5 +23,26 @@ replication streams, one history, and it works on the laptop.
An id is the entity's stable identity, never a human-facing name: a rename must
be a row somewhere, not a file move and a severed history.
## From a chosen name, to a place
```go
slug := namespace.Sanitize(org) // one injective slug
ns, err := namespace.OrgProject(org, project) // org/<slug>[/<project>]
key, err := namespace.Key(ns, "tracker") // orgs/<slug>/tracker.db
path, err := namespace.Path(dataDir, ns, "tracker") // <dataDir>/orgs/<slug>/tracker.db
```
`Sanitize` reduces a name a person chose — an IAM org, a project — to a
`[a-z0-9-]` slug and is **injective**: names carrying an invisible rune are
refused outright (nothing downstream can be trusted not to trim them), and every
other fold is disambiguated by a hash of the raw bytes, so `Acme` and `acme` stay
two orgs and therefore two files. `OrgProject` is the door those names walk
through to become a namespace.
`Key` renders a namespace as the place one subsystem's file for it lives, and
`Path` resolves that against a data directory. One rendering, two consumers: the
local file and the object key durability ships it to are the same string by
construction, so they cannot drift apart.
`hanzoai/orm/db.Namespaces` resolves a namespace to an open handle — materialise,
open, cache, evict. That is the only other thing with an opinion about namespaces.
+73
View File
@@ -0,0 +1,73 @@
// Copyright (C) 2026, Hanzo AI. All rights reserved.
// See the file LICENSE for licensing terms.
package namespace
import (
"fmt"
"path"
"path/filepath"
)
// orgsRoot is the single directory every namespace's files live under, and
// platformSlug is how the system namespace renders inside it — a leading "_",
// which Sanitize never emits, so the deployment's own partition cannot collide
// with an entity's however the slugger changes.
//
// Both are spelled once, here, because these strings are on disk and in object
// storage: a second spelling is a second answer to "where is this store", and
// the files that already exist only have one place.
const (
orgsRoot = "orgs"
platformSlug = "_platform"
)
// Key renders a namespace as the place one subsystem's file for it lives:
// relative to a data directory on disk, and byte-identically the object key
// durability ships it to. One rendering, two consumers, so the local file and
// the remote slot are the same name by construction.
//
// org/{id} → orgs/{id}/{subsystem}.db
// org/{id}/{group} → orgs/{id}/projects/{group}/{subsystem}.db
// system → orgs/_platform/{subsystem}.db
//
// The subsystem is a separate argument rather than part of the namespace
// because one store is one subsystem: which subsystem is a property of the
// registry that opens the file, not of the entity the file belongs to.
//
// Every other kind is an error. User and repo namespaces are real namespaces,
// but this layout has no place for them, and inventing one silently is how a
// second convention starts.
func Key(ns Namespace, subsystem string) (string, error) {
if subsystem == "" {
return "", fmt.Errorf("namespace: empty subsystem")
}
switch ns.Kind() {
case KindSystem:
return path.Join(orgsRoot, platformSlug, subsystem+".db"), nil
case KindOrg:
if g := ns.Group().String(); g != "" {
return path.Join(orgsRoot, ns.ID(), "projects", g, subsystem+".db"), nil
}
return path.Join(orgsRoot, ns.ID(), subsystem+".db"), nil
case "":
return "", fmt.Errorf("namespace: the zero namespace names no database")
default:
return "", fmt.Errorf("namespace: no database layout for a %s namespace", ns.Kind())
}
}
// Path is Key resolved against a deployment's data directory, in the host's own
// separator. An empty dir is an error rather than a relative path, because a
// store that opened relative to whatever the working directory happened to be
// would be a different file per invocation.
func Path(dir string, ns Namespace, subsystem string) (string, error) {
if dir == "" {
return "", fmt.Errorf("namespace: empty dir")
}
key, err := Key(ns, subsystem)
if err != nil {
return "", err
}
return filepath.Join(dir, filepath.FromSlash(key)), nil
}
+160
View File
@@ -0,0 +1,160 @@
// Copyright (C) 2026, Hanzo AI. All rights reserved.
// See the file LICENSE for licensing terms.
package namespace
import "testing"
// TestKeyIsTheStringOnDisk is a GOLDEN test, and the golden values are not
// aspirational: every one of them was produced by the implementation that has
// been writing these files in production, captured before the code moved here.
//
// It is a table of literals rather than a property, because a property test
// proves the new code is self-consistent and this has to prove something else —
// that the new code agrees with the old one to the byte. These strings are
// directory names on a live volume and object keys in a live bucket. A rendering
// that changed by one character would not fail; it would silently open an empty
// database next to an org's real one.
//
// The paths are the POSIX rendering, which is what every host in the fleet runs.
func TestKeyIsTheStringOnDisk(t *testing.T) {
for _, tc := range []struct{ org, project, subsystem, key, path string }{
// A clean slug is itself: the common case reads as the org's own name.
{"acme", "", "git", "orgs/acme/git.db", "/data/orgs/acme/git.db"},
{"acme", "", "kms", "orgs/acme/kms.db", "/data/orgs/acme/kms.db"},
{"9lives", "", "git", "orgs/9lives/git.db", "/data/orgs/9lives/git.db"},
// A project is a real nested segment, including the one called "default".
{"acme", "web", "tracker", "orgs/acme/projects/web/tracker.db", "/data/orgs/acme/projects/web/tracker.db"},
{"acme", "default", "tracker", "orgs/acme/projects/default/tracker.db", "/data/orgs/acme/projects/default/tracker.db"},
// The folding path: case, "_" and "." each fold to the same shape and are
// then held apart by a hash of the raw bytes. "acme"/"ACME"/"Acme" are
// three orgs and three files.
{"ACME", "", "git", "orgs/acme-a8308e8e2e4b7330/git.db", "/data/orgs/acme-a8308e8e2e4b7330/git.db"},
{"Acme", "", "git", "orgs/acme-37036cd8f9746d33/git.db", "/data/orgs/acme-37036cd8f9746d33/git.db"},
{"acme_corp", "", "git", "orgs/acme-corp-cca8c7942f8c15a2/git.db", "/data/orgs/acme-corp-cca8c7942f8c15a2/git.db"},
{"acme.corp", "", "git", "orgs/acme-corp-80a1d5cc623b165f/git.db", "/data/orgs/acme-corp-80a1d5cc623b165f/git.db"},
{"MaxPower", "", "kms", "orgs/maxpower-a3e10398a4a44f2e/kms.db", "/data/orgs/maxpower-a3e10398a4a44f2e/kms.db"},
{"x-y_z", "", "git", "orgs/x-y-z-988a16a3150c2188/git.db", "/data/orgs/x-y-z-988a16a3150c2188/git.db"},
{"über", "", "git", "orgs/ber-b51c854170449c38/git.db", "/data/orgs/ber-b51c854170449c38/git.db"},
{"Ünïcödé", "", "git", "orgs/n-c-d-39af95d07d82b5d6/git.db", "/data/orgs/n-c-d-39af95d07d82b5d6/git.db"},
// Over 32 bytes: the fold truncates and the hash keeps two long names
// that share a prefix apart.
{"this-is-a-very-long-org-name-that-exceeds-thirty-two-characters", "", "git",
"orgs/this-is-a-very-long-org-name-tha-d6c796465b2f097c/git.db",
"/data/orgs/this-is-a-very-long-org-name-tha-d6c796465b2f097c/git.db"},
{"THIS-IS-A-VERY-LONG-ORG-NAME-THAT-EXCEEDS-THIRTY-TWO-CHARACTERS", "", "git",
"orgs/this-is-a-very-long-org-name-tha-e1e451cbed85af65/git.db",
"/data/orgs/this-is-a-very-long-org-name-tha-e1e451cbed85af65/git.db"},
// A name shaped like this function's own output is re-suffixed; one that
// merely resembles it (a "g" is not hex) is not.
{"foo-0123456789abcdef", "", "git",
"orgs/foo-0123456789abcdef-1225a522cf51bf26/git.db",
"/data/orgs/foo-0123456789abcdef-1225a522cf51bf26/git.db"},
{"foo-0123456789abcdeg", "", "git", "orgs/foo-0123456789abcdeg/git.db", "/data/orgs/foo-0123456789abcdeg/git.db"},
// A name written as a path becomes ONE segment and stays distinct from an
// org actually called "etc".
{"../etc", "", "git", "orgs/etc-f7f9121fdb53d8de/git.db", "/data/orgs/etc-f7f9121fdb53d8de/git.db"},
{"a/b", "", "git", "orgs/a-b-c14cddc033f64b9d/git.db", "/data/orgs/a-b-c14cddc033f64b9d/git.db"},
{"..%2f..%2fvictim", "", "git",
"orgs/2f---2fvictim-26573cabaaa3ba65/git.db",
"/data/orgs/2f---2fvictim-26573cabaaa3ba65/git.db"},
// The project half folds by the same rule, in the group slot.
{"acme", "Alpha", "git",
"orgs/acme/projects/alpha-b1a96dd646bccaa2/git.db",
"/data/orgs/acme/projects/alpha-b1a96dd646bccaa2/git.db"},
{"acme", "beta.1", "tracker",
"orgs/acme/projects/beta-1-e6156cf33db02273/tracker.db",
"/data/orgs/acme/projects/beta-1-e6156cf33db02273/tracker.db"},
{"acme", "../etc", "git",
"orgs/acme/projects/etc-f7f9121fdb53d8de/git.db",
"/data/orgs/acme/projects/etc-f7f9121fdb53d8de/git.db"},
{"Acme", "Alpha", "kms",
"orgs/acme-37036cd8f9746d33/projects/alpha-b1a96dd646bccaa2/kms.db",
"/data/orgs/acme-37036cd8f9746d33/projects/alpha-b1a96dd646bccaa2/kms.db"},
} {
ns, err := OrgProject(tc.org, tc.project)
if err != nil {
t.Errorf("OrgProject(%q, %q): %v", tc.org, tc.project, err)
continue
}
key, err := Key(ns, tc.subsystem)
if err != nil {
t.Errorf("Key(%q, %q): %v", ns, tc.subsystem, err)
continue
}
if key != tc.key {
t.Errorf("Key(OrgProject(%q, %q), %q) = %q, want %q", tc.org, tc.project, tc.subsystem, key, tc.key)
}
p, err := Path("/data", ns, tc.subsystem)
if err != nil {
t.Errorf("Path(%q, %q): %v", ns, tc.subsystem, err)
continue
}
if p != tc.path {
t.Errorf("Path(\"/data\", OrgProject(%q, %q), %q) = %q, want %q", tc.org, tc.project, tc.subsystem, p, tc.path)
}
}
}
// TestKeySystemPartition pins the deployment's own partition. Its directory
// carries a "_", which Sanitize never emits, so no entity's namespace can be
// rendered onto it — the reason it survived the move to a distinct KIND
// unchanged, and the reason the files already on disk keep their path.
func TestKeySystemPartition(t *testing.T) {
for _, tc := range []struct{ subsystem, key, path string }{
{"kms", "orgs/_platform/kms.db", "/data/orgs/_platform/kms.db"},
{"git", "orgs/_platform/git.db", "/data/orgs/_platform/git.db"},
} {
key, err := Key(System(), tc.subsystem)
if err != nil {
t.Fatalf("Key(system, %q): %v", tc.subsystem, err)
}
if key != tc.key {
t.Errorf("Key(system, %q) = %q, want %q", tc.subsystem, key, tc.key)
}
p, err := Path("/data", System(), tc.subsystem)
if err != nil {
t.Fatalf("Path(system, %q): %v", tc.subsystem, err)
}
if p != tc.path {
t.Errorf("Path(\"/data\", system, %q) = %q, want %q", tc.subsystem, p, tc.path)
}
}
// This layout has no nesting under the system partition, so a group on the
// system namespace does not reach the rendering: system/notices and system
// name the SAME file. That is what the code on disk does today and it is
// pinned here rather than left to be discovered — the instance's own data is
// kept apart by SUBSYSTEM here, not by group.
if got, err := Key(System().WithGroup(MustGroup("notices")), "kms"); err != nil || got != "orgs/_platform/kms.db" {
t.Errorf("Key(system/notices, %q) = %q, %v; want orgs/_platform/kms.db", "kms", got, err)
}
}
// TestKeyRefuses pins the fail-closed half. Each of these would, if it returned
// a string, be a file quietly shared by callers who meant different things.
func TestKeyRefuses(t *testing.T) {
if _, err := Key(MustOrg("acme"), ""); err == nil {
t.Error("an empty subsystem must error — it would name orgs/acme/.db")
}
if _, err := Key(Namespace{}, "git"); err == nil {
t.Error("the zero namespace must error — it names no database")
}
for _, ns := range []Namespace{
MustUser("alice"),
MustRepo("r1"),
MustUser("alice").WithGroup(MustGroup("issues")),
} {
if _, err := Key(ns, "git"); err == nil {
t.Errorf("Key(%q) must error: this layout has no place for a %s namespace", ns, ns.Kind())
}
}
if _, err := Path("", System(), "git"); err == nil {
t.Error("an empty dir must error — the store would move with the working directory")
}
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright (C) 2026, Hanzo AI. All rights reserved.
// See the file LICENSE for licensing terms.
package namespace
import "fmt"
// OrgProject names the database an org's records live in — or, when project is
// non-empty, the database that org's records for one project live in.
//
// It is the door for names a PERSON chose. Org and MustOrg take an id that is
// already legal; OrgProject takes an IAM org and a project as they are written
// down, folds each through Sanitize — the one injective slugger — and only then
// builds the namespace. So two distinct orgs can never share a database: a case
// fold on a case-insensitive filesystem, or a "-"/"." fold, would otherwise
// collapse them onto one file.
//
// org MUST be a value the caller has already authenticated — a validated claim,
// or a server-side resolution the caller states as its contract — never a raw
// request body or header. Sanitize makes a hostile name HARMLESS (it cannot
// escape its directory) but it cannot make it AUTHORISED, and those are
// different questions answered in different places.
//
// The project rides in the GROUP slot, which is what a group is for: which of
// this entity's related types this file holds.
//
// It is stricter than folding alone in exactly one place, and the strictness is
// the point. Sanitize emits a leading "-" for the two names that fold to
// nothing printable (one that folds away entirely, such as "!!!", and one
// literally written "-abc", which takes the identity fast path). A namespace
// segment must begin with [a-z0-9], so those error here rather than quietly
// minting a directory named "-<hash>".
func OrgProject(org, project string) (Namespace, error) {
slug := Sanitize(org)
if slug == "" {
return Namespace{}, fmt.Errorf("namespace: invalid org %q", org)
}
ns, err := Org(slug)
if err != nil {
return Namespace{}, fmt.Errorf("namespace: invalid org %q: %w", org, err)
}
if project == "" {
return ns, nil
}
projSlug := Sanitize(project)
if projSlug == "" {
return Namespace{}, fmt.Errorf("namespace: invalid project %q", project)
}
g, err := NewGroup(projSlug)
if err != nil {
return Namespace{}, fmt.Errorf("namespace: invalid project %q: %w", project, err)
}
return ns.WithGroup(g), nil
}
// MustOrgProject is OrgProject for names fixed in the source — a test, a seed, a
// constant in a migration. It panics, which is correct for a value that is wrong
// before the program runs and wrong for anything from a request.
func MustOrgProject(org, project string) Namespace { return must(OrgProject(org, project)) }
+116
View File
@@ -0,0 +1,116 @@
// Copyright (C) 2026, Hanzo AI. All rights reserved.
// See the file LICENSE for licensing terms.
package namespace
import (
"path"
"strings"
"testing"
)
// TestOrgProjectRefuses pins the door shut on the names that must never become
// a database. Each of these would, if admitted, be a file two distinct entities
// could share or a segment that escapes the data directory.
func TestOrgProjectRefuses(t *testing.T) {
for _, tc := range []struct{ org, project, why string }{
{"", "", "an empty org names no entity"},
{"acme ", "", "a trailing space folds onto acme after any trim"},
{"acme\u200b", "", "a zero-width rune is invisible in an identifier"},
{"acme", " ", "a whitespace-only project names no group"},
{"!!!", "", "an org that folds to nothing would be named by its hash alone"},
{"-abc", "", "a slug starting with '-' is not a legal segment"},
{"acme", "!!!", "a project that folds to nothing would be named by its hash alone"},
} {
if ns, err := OrgProject(tc.org, tc.project); err == nil {
t.Errorf("OrgProject(%q, %q) = %q, want an error: %s", tc.org, tc.project, ns, tc.why)
}
}
}
// TestOrgProjectNeutralisesTraversal pins what happens to a name that looks
// like a path. It is not refused — Sanitize folds every separator and dot to
// "-" and then disambiguates with a hash of the raw name, so "../etc" becomes
// one safe segment that is still distinct from an org actually called "etc".
// The property that matters is not "rejected" but "cannot leave its directory",
// and asserting the wrong one of those would be a test that passes for the
// wrong reason.
func TestOrgProjectNeutralisesTraversal(t *testing.T) {
hostile := []string{"../etc", "..", ".", "a/b", "..%2f..%2fvictim", `a\b`}
for _, org := range hostile {
ns, err := OrgProject(org, "")
if err != nil {
continue // refused outright is also fine
}
id := ns.ID()
if id == "." || id == ".." || strings.ContainsAny(id, `/\`) {
t.Errorf("OrgProject(%q) = %q, whose id %q can leave its directory", org, ns, id)
}
key, err := Key(ns, "widget")
if err != nil {
t.Fatalf("Key(%q): %v", ns, err)
}
if want := path.Join(orgsRoot, id, "widget.db"); key != want {
t.Errorf("OrgProject(%q) rendered to %q, want %q", org, key, want)
}
}
for _, project := range hostile {
ns, err := OrgProject("acme", project)
if err != nil {
continue
}
if g := ns.Group().String(); g == "." || g == ".." || strings.ContainsAny(g, `/\`) {
t.Errorf("project %q became group %q, which can leave its directory", project, g)
}
if ns.ID() != "acme" {
t.Errorf("project %q changed the org to %q", project, ns.ID())
}
}
}
// TestOrgProjectIsInjective proves the property the whole boundary rests on:
// two distinct orgs never name one database. Sanitize carries it, and Org's own
// case fold cannot undo it, because Sanitize has already lowercased everything
// it emits.
func TestOrgProjectIsInjective(t *testing.T) {
seen := map[string]string{}
for _, org := range []string{
"acme", "ACME", "Acme", "acme-corp", "acme_corp", "acme.corp",
"team-a", "team.a", "team_a", "platform", "system", "org",
} {
ns, err := OrgProject(org, "")
if err != nil {
t.Fatalf("OrgProject(%q): %v", org, err)
}
if prev, dup := seen[ns.String()]; dup {
t.Fatalf("orgs %q and %q name the SAME database %q", prev, org, ns)
}
seen[ns.String()] = org
}
// And no org can reach the deployment's own partition, whatever it is called.
for _, org := range []string{"_platform", "platform", "system", "_system"} {
ns, err := OrgProject(org, "")
if err != nil {
continue
}
if ns == System() {
t.Fatalf("org %q named the system partition", org)
}
if k, err := Key(ns, "kms"); err == nil {
if p, _ := Key(System(), "kms"); k == p {
t.Fatalf("org %q rendered to the system partition's file %q", org, k)
}
}
}
}
// TestMustOrgProjectPanics states what the Must form is for: a name fixed in
// the source, wrong before the program runs.
func TestMustOrgProjectPanics(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("MustOrgProject on an invalid org must panic")
}
}()
MustOrgProject("", "")
}
+114
View File
@@ -0,0 +1,114 @@
// Copyright (C) 2026, Hanzo AI. All rights reserved.
// See the file LICENSE for licensing terms.
package namespace
import (
"crypto/sha256"
"encoding/hex"
"strings"
"unicode"
)
// Sanitize reduces an externally-chosen name — an IAM org, a project — to a
// lowercase [a-z0-9-] slug, and it is INJECTIVE: two distinct inputs never
// share an output.
//
// Injectivity is the whole point, because the slug becomes the name of a
// DATABASE. A lossy fold is not a cosmetic defect there, it is two entities
// reading and writing one file. The three ways a fold loses information are
// each closed:
//
// - Invisible runes are REFUSED (→ ""). A name carrying whitespace, a
// control rune or a zero-width format rune cannot be folded injectively,
// because strings.TrimSpace and an HTTP header's own OWS trim drop them
// later regardless of what this function does — "acme " would arrive as
// "acme". Refusing is the only answer that survives transport. Callers gate
// on "", so this is fail-secure.
// - Case and punctuation are folded, then DISAMBIGUATED: every accepted name
// that is not already a clean slug maps to its fold plus "-" and the first
// 16 hex of SHA-256 over the RAW input bytes. Without the suffix, ToLower,
// every non-[a-z0-9-] rune → "-", and the 32-byte truncation would collapse
// "Acme"/"acme" and "team.a"/"team-a" onto one name.
// - A clean slug that ITSELF looks like this function's own output —
// <label>-<16 lowercase hex> — is denied the identity fast path and
// re-suffixed, so an entity that squats on the rendered form of another
// name cannot alias it. That is the only collision class the fast path
// could otherwise admit.
//
// It is the identity on a clean, unambiguous, ≤32-byte DNS-1123 label, which is
// what almost every real name is — so the common case reads as itself on disk.
//
// It does NOT return an error, and that is deliberate: "" is the one refusal,
// every caller already has to decide what to do about a name it cannot use, and
// a second failure mode would only be a second thing to forget to check.
func Sanitize(s string) string {
if s == "" || hasUnsafeRune(s) {
return ""
}
lower := strings.ToLower(s)
if isDNSLabel(lower) && lower == s && len(lower) <= 32 && !looksSuffixed(lower) {
return lower // already a clean, unambiguous slug: identity, no suffix needed
}
var b strings.Builder
for _, r := range lower {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
folded := strings.Trim(b.String(), "-")
if len(folded) > 32 {
folded = strings.Trim(folded[:32], "-")
}
// The suffix is derived from the EXACT input bytes — never a trimmed or
// folded copy — so two names that fold together stay apart unless SHA-256
// itself collides on them.
sum := sha256.Sum256([]byte(s))
return folded + "-" + hex.EncodeToString(sum[:8])
}
// hasUnsafeRune reports whether s carries any whitespace, control, or
// zero-width/format rune: the class no injective fold survives, because
// something downstream trims it. Case, "-", "." and other VISIBLE punctuation
// are deliberately not in it — those fold injectively through the hash above.
func hasUnsafeRune(s string) bool {
for _, r := range s {
if unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) {
return true
}
}
return false
}
// isDNSLabel reports whether s is a non-empty [a-z0-9-] string that is not "."
// or "..". Such a name needs no disambiguation — Sanitize is the identity on it.
func isDNSLabel(s string) bool {
if s == "" || s == "." || s == ".." {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
default:
return false
}
}
return true
}
// looksSuffixed reports whether s ends in "-" and exactly 16 lowercase-hex
// characters — the shape of Sanitize's own disambiguation.
func looksSuffixed(s string) bool {
if len(s) < 17 || s[len(s)-17] != '-' {
return false
}
for _, r := range s[len(s)-16:] {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
return false
}
}
return true
}
+163
View File
@@ -0,0 +1,163 @@
// Copyright (C) 2026, Hanzo AI. All rights reserved.
// See the file LICENSE for licensing terms.
package namespace
import "testing"
// sanitizeGolden is the same corpus twice over: it is the table
// TestSanitizeIsTheStringOnDisk asserts, and the input set the injectivity and
// safety proofs below sweep. One list, so a case added to pin a rendering is
// also a case the properties must survive.
//
// Every want was captured from the implementation that produced the slugs
// already on disk, before the code moved into this package.
var sanitizeGolden = []struct{ in, want string }{
// Identity on a clean, unambiguous, ≤32-byte label.
{"acme", "acme"},
{"acme-corp", "acme-corp"},
{"team-a", "team-a"},
{"my-team-42", "my-team-42"},
{"maxpower", "maxpower"},
{"platform", "platform"},
{"system", "system"},
{"org", "org"},
{"a", "a"},
{"0", "0"},
{"z9", "z9"},
{"9lives", "9lives"},
// Case folds, and the hash of the RAW bytes keeps the spellings apart.
{"ACME", "acme-a8308e8e2e4b7330"},
{"Acme", "acme-37036cd8f9746d33"},
{"MaxPower", "maxpower-a3e10398a4a44f2e"},
// Punctuation folds to "-", so these three would otherwise be one name.
{"acme_corp", "acme-corp-cca8c7942f8c15a2"},
{"acme.corp", "acme-corp-80a1d5cc623b165f"},
{"team.a", "team-a-dc6273296fb8abe9"},
{"team_a", "team-a-112cd0cb52eea635"},
{"x-y_z", "x-y-z-988a16a3150c2188"},
// A leading "_" is folded away, which is why the system partition's
// "_platform" directory is unreachable from any name.
{"_platform", "platform-7583da5924deda8d"},
{"_system", "system-ee3df3979817a58d"},
// Non-ASCII is dropped to "-" and disambiguated; a name that folds to
// nothing at all is the hash alone, behind a leading "-".
{"über", "ber-b51c854170449c38"},
{"Ünïcödé", "n-c-d-39af95d07d82b5d6"},
{"日本語", "-77710aedc74ecfa3"},
{"!!!", "-e84c538e7fe25073"},
{".", "-cdb4ee2aea69cc6a"},
{"..", "-5ec1f7e700f37c3d"},
// A name written as a path becomes one segment.
{"../etc", "etc-f7f9121fdb53d8de"},
{"a/b", "a-b-c14cddc033f64b9d"},
{`a\b`, "a-b-c62016d0f8ee3333"},
{"..%2f..%2fvictim", "2f---2fvictim-26573cabaaa3ba65"},
// Over 32 bytes: truncate the fold, keep the hash of the whole input.
{"this-is-a-very-long-org-name-that-exceeds-thirty-two-characters", "this-is-a-very-long-org-name-tha-d6c796465b2f097c"},
{"THIS-IS-A-VERY-LONG-ORG-NAME-THAT-EXCEEDS-THIRTY-TWO-CHARACTERS", "this-is-a-very-long-org-name-tha-e1e451cbed85af65"},
// A clean label shaped like this function's own output is re-suffixed, so
// it cannot alias the folded name that renders that way. One with a "g" in
// the tail is not that shape, and keeps the fast path.
{"foo-0123456789abcdef", "foo-0123456789abcdef-1225a522cf51bf26"},
{"foo-0123456789abcdeg", "foo-0123456789abcdeg"},
// "-abc" is a clean label, so it is the identity — and therefore a slug no
// namespace segment will accept. Refusing it is OrgProject's job, not this
// one's; pinned here so the division stays visible.
{"-abc", "-abc"},
// Refused: empty, or carrying a rune something downstream trims away.
{"", ""},
{"acme ", ""},
{" acme", ""},
{"ACME CORP", ""},
{"acme\u200b", ""},
{"acme corp", ""},
{"acme\tcorp", ""},
{"acme\ncorp", ""},
{"acme\x00", ""},
}
// TestSanitizeIsTheStringOnDisk is the byte-identity proof. A slug is a
// directory name on a live volume and a key prefix in a live bucket, so a
// rendering that drifts by one character does not fail — it opens an empty
// database beside an entity's real one.
func TestSanitizeIsTheStringOnDisk(t *testing.T) {
for _, tc := range sanitizeGolden {
if got := Sanitize(tc.in); got != tc.want {
t.Errorf("Sanitize(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// TestSanitizeIsInjective is the property the whole boundary rests on: two
// distinct names never render to one database. Refusals are excluded — "" is
// not an output, it is the absence of one, and every caller gates on it.
func TestSanitizeIsInjective(t *testing.T) {
seen := map[string]string{}
for _, tc := range sanitizeGolden {
got := Sanitize(tc.in)
if got == "" {
continue
}
if prev, dup := seen[got]; dup {
t.Errorf("%q and %q both render to %q — one database, two entities", prev, tc.in, got)
}
seen[got] = tc.in
}
}
// TestSanitizeOutputIsOneSafeSegment pins what an accepted name can BE: lower
// ASCII, digits and "-", never a separator, a dot or a traversal. This is what
// makes a hostile name harmless rather than merely unusual.
func TestSanitizeOutputIsOneSafeSegment(t *testing.T) {
for _, tc := range sanitizeGolden {
got := Sanitize(tc.in)
if got == "" {
continue
}
if got == "." || got == ".." {
t.Errorf("Sanitize(%q) = %q, which names a directory above its own", tc.in, got)
}
for _, r := range got {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
default:
t.Errorf("Sanitize(%q) = %q, which carries %q", tc.in, got, string(r))
}
}
}
}
// TestSanitizeIsIdempotentOnlyWhereItIsTheIdentity is the trap the "looks
// suffixed" rule exists for. Sanitize is NOT idempotent on a folded name, and
// must not be re-applied to a slug it already produced: doing so would mint a
// second, different name for the same entity. Feeding its output back in is a
// bug, and this states which way round it fails.
func TestSanitizeIsIdempotentOnlyWhereItIsTheIdentity(t *testing.T) {
for _, tc := range sanitizeGolden {
once := Sanitize(tc.in)
if once == "" {
continue
}
twice := Sanitize(once)
if once == tc.in {
if twice != once {
t.Errorf("Sanitize is the identity on %q but Sanitize(Sanitize(%q)) = %q", tc.in, tc.in, twice)
}
continue
}
if twice == once {
t.Errorf("Sanitize(%q) = %q survived re-folding — a folded slug must never "+
"be indistinguishable from a raw name that needs one", tc.in, once)
}
}
}