mirror of
https://github.com/hanzoai/namespace.git
synced 2026-09-30 22:57:59 +00:00
namespace: a value that names one entity database
The fleet stores OLTP data as one SQLite file per entity, and the name of that file is a path -- <dir>/<ns>.db locally, a key prefix in object storage. orm/db canonicalised it with path.Clean plus "must begin with a letter", which leaves a namespace an opaque string that every consumer re-derives structure from and re-validates: orm/replicated re-split and re-checked each segment, commerce/db has splitTenant. So make it a value. Namespace wraps an unexported string, the only routes to one are the constructors and Parse, and the shape is kind[/id][/group]. Segments are [a-z0-9][a-z0-9_-]*, which leaves no spelling of a separator, a dot-segment or a hidden name to reject later, and ids fold to lowercase because on a case-insensitive filesystem org/Acme and org/acme are two namespaces over one file. This package is what a namespace IS; orm/db.Namespaces resolves one to a handle. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/namespace
|
||||
*.test
|
||||
testdata/fuzz/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Hanzo AI Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,27 @@
|
||||
# namespace
|
||||
|
||||
Names the one database an entity's data lives in.
|
||||
|
||||
```go
|
||||
ns := namespace.MustRepo("42").WithGroup(Issues) // repo/42/issues
|
||||
ns, err := namespace.Org(id) // org/<id>
|
||||
namespace.System().WithGroup(Notices) // system/notices
|
||||
```
|
||||
|
||||
A namespace is `kind[/id][/group]`, and its string form IS a path: `<dir>/<ns>.db`
|
||||
on disk, a key prefix in object storage. So it is a value with constructors, not
|
||||
a string with a validator — `Namespace` wraps an unexported field, and the only
|
||||
routes to one are the constructors and `Parse`. Illegal names do not exist to be
|
||||
caught.
|
||||
|
||||
Two rules do the work. Segments are `[a-z0-9][a-z0-9_-]*`, which leaves no
|
||||
spelling of a separator, a dot-segment or a hidden name to reject later. And ids
|
||||
fold to lowercase at construction, because on a case-insensitive filesystem
|
||||
`org/Acme` and `org/acme` are two namespaces over one file — two handles, two
|
||||
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.
|
||||
|
||||
`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.
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
// Package namespace names the one database an entity's data lives in.
|
||||
//
|
||||
// The fleet stores OLTP data as one SQLite file per entity — an org, a user, a
|
||||
// repository — rather than one per tenant, so that a file stays small and
|
||||
// several can be opened at once. A namespace is the name of one of those files.
|
||||
//
|
||||
// It is a value with constructors and not a string with a validator, because
|
||||
// the string form IS a path: it becomes <dir>/<namespace>.db locally and a key
|
||||
// prefix in object storage. A name that a validator lets through late is a name
|
||||
// that has already been written down somewhere by then. Here there is no route
|
||||
// to a Namespace that does not go through a constructor, so the illegal ones do
|
||||
// not exist to be caught.
|
||||
//
|
||||
// This package is what a namespace IS. Resolving one to an open handle —
|
||||
// materialise, open, cache, evict — is hanzoai/orm/db.Namespaces, and it is the
|
||||
// only other thing allowed an opinion about namespaces.
|
||||
package namespace
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Kind is the sort of entity whose data a namespace holds.
|
||||
//
|
||||
// The set is closed and adding to it is a patch to this file, on purpose: which
|
||||
// entities own their own data is one fleet-wide fact, and a fact with two homes
|
||||
// is a fact that disagrees with itself. It is also the reason Kind cannot be
|
||||
// injected — no exported function takes one — so a Kind literal that names
|
||||
// nothing can never reach a Namespace.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindOrg Kind = "org"
|
||||
KindUser Kind = "user"
|
||||
KindRepo Kind = "repo"
|
||||
KindSystem Kind = "system"
|
||||
)
|
||||
|
||||
// maxSegment bounds an id or a group. A filesystem component stops at 255 bytes
|
||||
// and an S3 key at 1024; 64 clears both with room for the kind, the group and
|
||||
// the ".db", and still holds a UUID (36) or a ULID (26) with most of it spare.
|
||||
const maxSegment = 64
|
||||
|
||||
// Namespace names one database: an entity kind, that entity's id, and
|
||||
// optionally a group naming which of its related entity types this file holds.
|
||||
//
|
||||
// It holds its own canonical string rather than the three parts, so String is
|
||||
// free and the map key hashes as one string — Namespaces keys every open handle
|
||||
// by this value and looks one up on every request. The parts are derived on the
|
||||
// rare path instead, which is the right way round.
|
||||
//
|
||||
// The zero Namespace is not a namespace. Go gives every struct a zero value and
|
||||
// no encapsulation takes that away, so it is defined rather than left to be
|
||||
// discovered: IsZero reports it, String returns "", MarshalText refuses it, and
|
||||
// whatever turns a namespace into a path must refuse it too. A zero value that
|
||||
// resolved to a file would be one file quietly shared by every caller who
|
||||
// forgot to set one — the worst failure this package exists to prevent, so it
|
||||
// is the one Go forces us to state explicitly.
|
||||
type Namespace struct{ s string }
|
||||
|
||||
// Group names a set of related entity types kept in a file of their own, so
|
||||
// that an entity's data is several small databases and not one that grows
|
||||
// without bound: a repository's issues need not be opened to read its settings.
|
||||
//
|
||||
// It is a type rather than a string because a group is CODE where an id is
|
||||
// DATA. A package declares the groups it owns once —
|
||||
//
|
||||
// var Issues = namespace.MustGroup("issues")
|
||||
//
|
||||
// — while an id arrives with a request. That is the whole reason the two are
|
||||
// built differently: MustGroup fails at init or never, so there is no error to
|
||||
// handle at a call site that cannot fail.
|
||||
//
|
||||
// The zero Group means no group, i.e. the entity's own data. That is a
|
||||
// definition, not a hole: ns.WithGroup(Group{}) is ns.
|
||||
type Group struct{ s string }
|
||||
|
||||
// Org, User and Repo name one entity's database.
|
||||
//
|
||||
// The id is the entity's STABLE identity, never a human-facing name. A
|
||||
// namespace is a path in object storage and a replication prefix; deriving it
|
||||
// from something a person can rename — a slug, an email, an owner/name pair —
|
||||
// makes a rename into a file move and a broken history. Transferring a
|
||||
// repository between orgs is likewise a row somewhere, not a namespace change,
|
||||
// which is why a repo is repo/<id> and never org/<org>/repo/<name>.
|
||||
func Org(id string) (Namespace, error) { return of(KindOrg, id) }
|
||||
func User(id string) (Namespace, error) { return of(KindUser, id) }
|
||||
func Repo(id string) (Namespace, error) { return of(KindRepo, id) }
|
||||
|
||||
// System is the instance's own namespace: what belongs to the deployment rather
|
||||
// than to any org, user or repo — app state, login sources, topics, notices.
|
||||
//
|
||||
// It takes no id because there is one instance. Groups keep its contents in
|
||||
// separate files (System().WithGroup(Notices)), so "instance-level" costs a
|
||||
// namespace and not an exception to the rule that every table lives in one.
|
||||
func System() Namespace { return Namespace{string(KindSystem)} }
|
||||
|
||||
// MustOrg, MustUser and MustRepo are for ids fixed in the source — tests,
|
||||
// seeds, a constant in a migration. They panic, which is correct for a value
|
||||
// that is wrong before the program runs and wrong for anything from a request.
|
||||
func MustOrg(id string) Namespace { return must(Org(id)) }
|
||||
func MustUser(id string) Namespace { return must(User(id)) }
|
||||
func MustRepo(id string) Namespace { return must(Repo(id)) }
|
||||
|
||||
// NewGroup returns the group of that name.
|
||||
func NewGroup(name string) (Group, error) {
|
||||
c, err := ident(name)
|
||||
if err != nil {
|
||||
return Group{}, fmt.Errorf("namespace: group: %w", err)
|
||||
}
|
||||
return Group{c}, nil
|
||||
}
|
||||
|
||||
// MustGroup is NewGroup for a group named in the source, which is all of them.
|
||||
func MustGroup(name string) Group {
|
||||
g, err := NewGroup(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// of is the one place a Namespace is built from parts, so the rule about which
|
||||
// kinds exist and which of them name an entity is stated once.
|
||||
func of(k Kind, id string) (Namespace, error) {
|
||||
switch k {
|
||||
case KindOrg, KindUser, KindRepo:
|
||||
case KindSystem:
|
||||
if id != "" {
|
||||
return Namespace{}, fmt.Errorf("namespace: %s names the instance and takes no id, got %q", k, id)
|
||||
}
|
||||
return System(), nil
|
||||
default:
|
||||
return Namespace{}, fmt.Errorf("namespace: %q is not an entity kind", string(k))
|
||||
}
|
||||
c, err := ident(id)
|
||||
if err != nil {
|
||||
return Namespace{}, fmt.Errorf("namespace: %s id: %w", k, err)
|
||||
}
|
||||
return join(k, c, ""), nil
|
||||
}
|
||||
|
||||
func join(k Kind, id, group string) Namespace {
|
||||
s := string(k)
|
||||
if id != "" {
|
||||
s += "/" + id
|
||||
}
|
||||
if group != "" {
|
||||
s += "/" + group
|
||||
}
|
||||
return Namespace{s}
|
||||
}
|
||||
|
||||
// parts splits the canonical form back into what built it.
|
||||
func (ns Namespace) parts() (k Kind, id, group string) {
|
||||
kind, rest, _ := strings.Cut(ns.s, "/")
|
||||
a, b, _ := strings.Cut(rest, "/")
|
||||
if Kind(kind) == KindSystem {
|
||||
return KindSystem, "", a // the instance names no entity, so its one segment is a group
|
||||
}
|
||||
return Kind(kind), a, b
|
||||
}
|
||||
|
||||
// WithGroup returns this entity's namespace for one group of its related types.
|
||||
//
|
||||
// It REPLACES any group already set instead of nesting, so a namespace is
|
||||
// always kind[/id][/group] and there is never a second shape to parse. The zero
|
||||
// Namespace absorbs, because there is no entity to name a group of.
|
||||
func (ns Namespace) WithGroup(g Group) Namespace {
|
||||
k, id, _ := ns.parts()
|
||||
if k == "" {
|
||||
return ns
|
||||
}
|
||||
return join(k, id, g.s)
|
||||
}
|
||||
|
||||
func (ns Namespace) Kind() Kind { k, _, _ := ns.parts(); return k }
|
||||
func (ns Namespace) ID() string { _, id, _ := ns.parts(); return id }
|
||||
func (ns Namespace) Group() Group { _, _, g := ns.parts(); return Group{g} }
|
||||
func (ns Namespace) IsZero() bool { return ns.s == "" }
|
||||
func (ns Namespace) String() string { return ns.s }
|
||||
|
||||
func (g Group) String() string { return g.s }
|
||||
|
||||
// Parse reads a namespace back from its string form, for the strings that
|
||||
// arrive from outside a Go program: a request, a config file, a queue message.
|
||||
//
|
||||
// Parse(ns.String()) == ns for every ns a constructor returns, so a namespace
|
||||
// survives a round trip instead of degrading into a string with conventions
|
||||
// attached to it.
|
||||
//
|
||||
// It folds case exactly as the constructors do and repairs nothing else.
|
||||
// "Org/Acme" is org/acme because a namespace is a value and "Acme" and "acme"
|
||||
// are one org — one value, one spelling, which is canonicalisation. "org//acme"
|
||||
// and "org/acme/" are errors, because String never emits them and accepting
|
||||
// them would make one namespace have several encodings — which is the aliasing
|
||||
// this package removes, arriving through the back door.
|
||||
func Parse(s string) (Namespace, error) {
|
||||
kind, rest, hasRest := strings.Cut(s, "/")
|
||||
k, err := ident(kind)
|
||||
if err != nil {
|
||||
return Namespace{}, fmt.Errorf("namespace: %q: kind: %w", s, err)
|
||||
}
|
||||
id, group, hasGroup := strings.Cut(rest, "/")
|
||||
if Kind(k) == KindSystem {
|
||||
id, group, hasGroup = "", rest, hasRest // no entity, so all of rest is the group
|
||||
}
|
||||
if strings.Contains(group, "/") {
|
||||
return Namespace{}, fmt.Errorf("namespace: %q has too many segments, want kind[/id][/group]", s)
|
||||
}
|
||||
ns, err := of(Kind(k), id)
|
||||
if err != nil {
|
||||
return Namespace{}, err
|
||||
}
|
||||
if !hasGroup {
|
||||
return ns, nil
|
||||
}
|
||||
g, err := NewGroup(group)
|
||||
if err != nil {
|
||||
return Namespace{}, fmt.Errorf("namespace: %q: %w", s, err)
|
||||
}
|
||||
return ns.WithGroup(g), nil
|
||||
}
|
||||
|
||||
// MarshalText and UnmarshalText put every encoder that speaks text — JSON, YAML,
|
||||
// a flag, a database column — through Parse and String rather than around them.
|
||||
// Without MarshalText, json.Marshal of a Namespace emits {}: the fields that
|
||||
// make illegal values unrepresentable also make them invisible to reflection,
|
||||
// so this is not a convenience, it is the difference between a namespace and
|
||||
// silent data loss.
|
||||
//
|
||||
// Marshalling the zero Namespace is an error rather than "": the mistake is at
|
||||
// the write, and "" would only fail later at whoever reads it back.
|
||||
func (ns Namespace) MarshalText() ([]byte, error) {
|
||||
if ns.IsZero() {
|
||||
return nil, errors.New("namespace: the zero Namespace names no database")
|
||||
}
|
||||
return []byte(ns.s), nil
|
||||
}
|
||||
|
||||
func (ns *Namespace) UnmarshalText(b []byte) error {
|
||||
p, err := Parse(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*ns = p
|
||||
return nil
|
||||
}
|
||||
|
||||
// ident canonicalises one segment — an id, a group or a kind — and is the whole
|
||||
// of what a namespace may be made of.
|
||||
//
|
||||
// Lowercase, because a namespace is a FILE and half the fleet develops on a
|
||||
// case-insensitive filesystem. There "org/Acme" and "org/acme" are two
|
||||
// namespaces over one file: two open handles, two replication streams, one
|
||||
// history, and it works on the laptop. Folding at construction removes the
|
||||
// difference rather than hoping every caller normalises first. ASCII-only, so
|
||||
// the fold is the same everywhere — Unicode lowercases Turkish "I" to a dotless
|
||||
// "ı", which would name a different file on a Turkish laptop than on a server.
|
||||
//
|
||||
// [a-z0-9][a-z0-9_-]* then settles what a segment cannot be by construction
|
||||
// instead of by denylist: no separator, so no path; no dot, so no ".." and no
|
||||
// hidden name; no trailing dot or space, which Windows and some object stores
|
||||
// drop; no empty segment; no percent- or unicode-encoded form of any of those.
|
||||
func ident(s string) (string, error) {
|
||||
if s == "" {
|
||||
return "", errors.New("must not be empty")
|
||||
}
|
||||
if len(s) > maxSegment {
|
||||
return "", fmt.Errorf("%q is %d bytes, over the %d-byte limit", s, len(s), maxSegment)
|
||||
}
|
||||
folded := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch c := s[i]; {
|
||||
case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
|
||||
case c >= 'A' && c <= 'Z':
|
||||
folded = true
|
||||
case (c == '-' || c == '_') && i > 0:
|
||||
default:
|
||||
return "", fmt.Errorf("%q: %q is not allowed, want [a-z0-9][a-z0-9_-]*", s, string(rune(c)))
|
||||
}
|
||||
}
|
||||
if !folded {
|
||||
return s, nil
|
||||
}
|
||||
return strings.ToLower(s), nil // provably ASCII by the loop above, so this is the ASCII fold
|
||||
}
|
||||
|
||||
func must(ns Namespace, err error) Namespace {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return ns
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package namespace
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
issues = MustGroup("issues")
|
||||
labels = MustGroup("labels")
|
||||
)
|
||||
|
||||
// every returns one of each shape a namespace comes in, which is what the
|
||||
// properties below are asserted over.
|
||||
func every() []Namespace {
|
||||
return []Namespace{
|
||||
MustOrg("acme"),
|
||||
MustOrg("acme").WithGroup(MustGroup("members")),
|
||||
MustUser("01hxyz9k7m"),
|
||||
MustUser("01hxyz9k7m").WithGroup(MustGroup("api_keys")),
|
||||
MustRepo("42"),
|
||||
MustRepo("42").WithGroup(issues),
|
||||
System(),
|
||||
System().WithGroup(MustGroup("notices")),
|
||||
}
|
||||
}
|
||||
|
||||
// dbPath is what orm/db.pathFor does with a namespace, reproduced so the
|
||||
// escape property is asserted against the real consumer and not a paraphrase.
|
||||
func dbPath(t *testing.T, dir string, ns Namespace) string {
|
||||
t.Helper()
|
||||
return filepath.Clean(filepath.Join(dir, filepath.FromSlash(ns.String())+".db"))
|
||||
}
|
||||
|
||||
func TestConstructors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ns Namespace
|
||||
s string
|
||||
kind Kind
|
||||
id string
|
||||
group string
|
||||
}{
|
||||
{"org", MustOrg("acme"), "org/acme", KindOrg, "acme", ""},
|
||||
{"org grouped", MustOrg("acme").WithGroup(MustGroup("members")), "org/acme/members", KindOrg, "acme", "members"},
|
||||
{"user", MustUser("z1"), "user/z1", KindUser, "z1", ""},
|
||||
{"user grouped", MustUser("z1").WithGroup(labels), "user/z1/labels", KindUser, "z1", "labels"},
|
||||
{"repo", MustRepo("42"), "repo/42", KindRepo, "42", ""},
|
||||
{"repo grouped", MustRepo("42").WithGroup(issues), "repo/42/issues", KindRepo, "42", "issues"},
|
||||
{"system", System(), "system", KindSystem, "", ""},
|
||||
{"system grouped", System().WithGroup(issues), "system/issues", KindSystem, "", "issues"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.ns.String(); got != tc.s {
|
||||
t.Errorf("String() = %q, want %q", got, tc.s)
|
||||
}
|
||||
if got := tc.ns.Kind(); got != tc.kind {
|
||||
t.Errorf("Kind() = %q, want %q", got, tc.kind)
|
||||
}
|
||||
if got := tc.ns.ID(); got != tc.id {
|
||||
t.Errorf("ID() = %q, want %q", got, tc.id)
|
||||
}
|
||||
if got := tc.ns.Group().String(); got != tc.group {
|
||||
t.Errorf("Group() = %q, want %q", got, tc.group)
|
||||
}
|
||||
if tc.ns.IsZero() {
|
||||
t.Error("reports zero")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An id is runtime data, so every constructor refuses the same set at the same
|
||||
// moment — before the value exists, not after it has been written down.
|
||||
func TestConstructorsRejectIllegalIDs(t *testing.T) {
|
||||
ctors := map[string]func(string) (Namespace, error){"Org": Org, "User": User, "Repo": Repo}
|
||||
for _, id := range []string{
|
||||
"", "..", ".", ".hidden", "a/b", "a\\b", "/a", "a/", "A B", "a.b", "a@b",
|
||||
"-x", "_x", "café", "a\x00b", "%2e%2e", "acme ", "acme.",
|
||||
strings.Repeat("a", maxSegment+1),
|
||||
} {
|
||||
for name, ctor := range ctors {
|
||||
if got, err := ctor(id); err == nil {
|
||||
t.Errorf("%s(%q) = %q, want an error", name, id, got)
|
||||
}
|
||||
}
|
||||
if got, err := NewGroup(id); err == nil {
|
||||
t.Errorf("NewGroup(%q) = %q, want an error", id, got)
|
||||
}
|
||||
}
|
||||
if got := len(strings.Repeat("a", maxSegment)); got != maxSegment { // the limit itself is legal
|
||||
t.Fatal("bad fixture")
|
||||
}
|
||||
if _, err := Org(strings.Repeat("a", maxSegment)); err != nil {
|
||||
t.Errorf("an id of exactly %d bytes: %v", maxSegment, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The instance names no entity, so there is no id that could qualify it. of is
|
||||
// the only door to that error because System takes no argument at all.
|
||||
func TestSystemTakesNoID(t *testing.T) {
|
||||
if got, err := of(KindSystem, "acme"); err == nil {
|
||||
t.Errorf("system with an id = %q, want an error", got)
|
||||
}
|
||||
if got, err := of(Kind("project"), "x"); err == nil {
|
||||
t.Errorf("an unknown kind = %q, want an error", got)
|
||||
}
|
||||
}
|
||||
|
||||
// One logical namespace has ONE value, so equality is the whole of comparison
|
||||
// and a map keyed by it cannot open one file under two entries.
|
||||
func TestCanonicalisationIsTotal(t *testing.T) {
|
||||
for _, spellings := range [][]string{
|
||||
{"acme", "Acme", "ACME", "aCmE"},
|
||||
{"01hxyz", "01HXYZ"},
|
||||
{"a-b_c", "A-B_C"},
|
||||
} {
|
||||
want := MustOrg(spellings[0])
|
||||
for _, id := range spellings[1:] {
|
||||
if got := MustOrg(id); got != want {
|
||||
t.Errorf("Org(%q) = %q, want %q", id, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if a, b := MustRepo("R7").WithGroup(MustGroup("Issues")), MustRepo("r7").WithGroup(issues); a != b {
|
||||
t.Errorf("%q != %q", a, b)
|
||||
}
|
||||
// Distinct values name distinct files: the aliasing bug, stated as a
|
||||
// property rather than trusted to the naming rules.
|
||||
seen := map[string]Namespace{}
|
||||
dir := t.TempDir()
|
||||
for _, ns := range every() {
|
||||
p := dbPath(t, dir, ns)
|
||||
if other, dup := seen[p]; dup {
|
||||
t.Errorf("%q and %q both name %q", ns, other, p)
|
||||
}
|
||||
seen[p] = ns
|
||||
}
|
||||
}
|
||||
|
||||
// A namespace survives a wire hop, a config file or a database column. If it
|
||||
// did not, it would decay into a string with conventions attached and every
|
||||
// consumer would re-derive them.
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
for _, ns := range every() {
|
||||
got, err := Parse(ns.String())
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(%q): %v", ns, err)
|
||||
}
|
||||
if got != ns {
|
||||
t.Errorf("Parse(%q) = %q, want %q", ns, got, ns)
|
||||
}
|
||||
b, err := json.Marshal(ns)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal(%q): %v", ns, err)
|
||||
}
|
||||
if want := `"` + ns.String() + `"`; string(b) != want {
|
||||
t.Errorf("Marshal(%q) = %s, want %s", ns, b, want)
|
||||
}
|
||||
var back Namespace
|
||||
if err := json.Unmarshal(b, &back); err != nil {
|
||||
t.Fatalf("Unmarshal(%s): %v", b, err)
|
||||
}
|
||||
if back != ns {
|
||||
t.Errorf("json round trip of %q gave %q", ns, back)
|
||||
}
|
||||
}
|
||||
var bad Namespace
|
||||
if err := json.Unmarshal([]byte(`"org/../etc"`), &bad); err == nil {
|
||||
t.Errorf("unmarshalled an illegal namespace as %q", bad)
|
||||
}
|
||||
if !bad.IsZero() {
|
||||
t.Errorf("a failed unmarshal left %q behind", bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFoldsCaseAndNothingElse(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"org/acme", "org/acme"},
|
||||
{"Org/Acme", "org/acme"},
|
||||
{"ORG/ACME/MEMBERS", "org/acme/members"},
|
||||
{"SYSTEM", "system"},
|
||||
{"System/Notices", "system/notices"},
|
||||
{"repo/42/issues", "repo/42/issues"},
|
||||
} {
|
||||
got, err := Parse(tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(%q): %v", tc.in, err)
|
||||
}
|
||||
if got.String() != tc.want {
|
||||
t.Errorf("Parse(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
if again, err := Parse(got.String()); err != nil || again != got {
|
||||
t.Errorf("Parse(%q) = %q, %v — not idempotent", got, again, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every name orm's canonical() had to reject is now unconstructible, and Parse
|
||||
// is the only door a raw string can come through.
|
||||
func TestParseRejects(t *testing.T) {
|
||||
for _, s := range append(escapeCorpus(),
|
||||
"", "org", "user", "repo", "ORG", "project/x", "system/acme/x",
|
||||
"org/acme/issues/extra", "org//acme", "org/acme/", "org/acme ",
|
||||
"org/a b", "org/a@b", "org/café", "org/a\x00b", "org/-x", "org/_x",
|
||||
"org/"+strings.Repeat("a", maxSegment+1),
|
||||
) {
|
||||
if got, err := Parse(s); err == nil {
|
||||
t.Errorf("Parse(%q) = %q, want an error", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// escapeCorpus is every way a string has ever been used to leave a directory.
|
||||
// None of them may become a Namespace, and TestNamespaceCannotEscape proves the
|
||||
// stronger claim: nothing that parses can, whatever it is spelled.
|
||||
func escapeCorpus() []string {
|
||||
return []string{
|
||||
"/", "//", "..", ".", "./org/acme", "/org/acme", "//org/acme",
|
||||
"../org/acme", "org/../../etc", "a/../../b", "org/./acme",
|
||||
"org/x/../acme", "org/..", "org/../..", "\\org\\acme",
|
||||
"..\\..\\etc", "org/%2e%2e/etc", "org/%2f/etc", "-org/acme",
|
||||
".hidden/acme", "org/.hidden", "c:/org/acme", "org/acme/../../..",
|
||||
"\u002e\u002e/etc", "org/\u2044etc", "org/acme\x00/../etc",
|
||||
}
|
||||
}
|
||||
|
||||
// The reason the type exists: its string form is a path, and no value of it can
|
||||
// name a file outside the cache directory however it is spelled.
|
||||
func TestNamespaceCannotEscape(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := filepath.Clean(dir) + string(filepath.Separator)
|
||||
check := func(ns Namespace) {
|
||||
t.Helper()
|
||||
if p := dbPath(t, dir, ns); !strings.HasPrefix(p, root) {
|
||||
t.Errorf("%q resolved to %q, outside %q", ns, p, root)
|
||||
}
|
||||
if n := strings.Count(ns.String(), "/"); n > 2 {
|
||||
t.Errorf("%q is %d deep, want at most kind/id/group", ns, n)
|
||||
}
|
||||
}
|
||||
for _, ns := range every() {
|
||||
check(ns)
|
||||
}
|
||||
for _, s := range escapeCorpus() {
|
||||
ns, err := Parse(s)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
t.Errorf("Parse(%q) = %q", s, ns)
|
||||
check(ns) // and if it ever does parse, it still may not escape
|
||||
}
|
||||
}
|
||||
|
||||
// The property, over anything at all rather than a corpus somebody thought of.
|
||||
func FuzzParse(f *testing.F) {
|
||||
for _, s := range append(escapeCorpus(), "org/acme", "repo/42/issues", "system", "System/Notices") {
|
||||
f.Add(s)
|
||||
}
|
||||
dir := filepath.FromSlash("/ns")
|
||||
root := filepath.Clean(dir) + string(filepath.Separator)
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
ns, err := Parse(s)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if ns.IsZero() {
|
||||
t.Fatalf("Parse(%q) succeeded with the zero Namespace", s)
|
||||
}
|
||||
if p := filepath.Clean(filepath.Join(dir, filepath.FromSlash(ns.String())+".db")); !strings.HasPrefix(p, root) {
|
||||
t.Fatalf("Parse(%q) = %q, which resolves to %q", s, ns, p)
|
||||
}
|
||||
again, err := Parse(ns.String())
|
||||
if err != nil || again != ns {
|
||||
t.Fatalf("Parse(%q) = %q, %v — not idempotent", ns, again, err)
|
||||
}
|
||||
if got := join(ns.Kind(), ns.ID(), ns.Group().String()); got != ns {
|
||||
t.Fatalf("%q split into %q/%q/%q, which rejoins as %q", ns, ns.Kind(), ns.ID(), ns.Group(), got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A group replaces, never nests, so there is exactly one shape to parse.
|
||||
func TestGroupReplaces(t *testing.T) {
|
||||
r := MustRepo("42")
|
||||
if got, want := r.WithGroup(issues).WithGroup(labels), r.WithGroup(labels); got != want {
|
||||
t.Errorf("regrouped to %q, want %q", got, want)
|
||||
}
|
||||
if got := r.WithGroup(issues).WithGroup(Group{}); got != r {
|
||||
t.Errorf("the zero Group gave %q, want the entity's own namespace %q", got, r)
|
||||
}
|
||||
if got := System().WithGroup(issues).WithGroup(Group{}); got != System() {
|
||||
t.Errorf("the zero Group on system gave %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The zero value is the one illegal Namespace Go permits. It is defined, and it
|
||||
// can never be mistaken for a real one.
|
||||
func TestZero(t *testing.T) {
|
||||
var ns Namespace
|
||||
if !ns.IsZero() || ns.String() != "" || ns.Kind() != "" || ns.ID() != "" {
|
||||
t.Errorf("zero Namespace = %q, kind %q, id %q", ns.String(), ns.Kind(), ns.ID())
|
||||
}
|
||||
if ns.WithGroup(issues) != ns {
|
||||
t.Error("the zero Namespace grew a group")
|
||||
}
|
||||
if _, err := ns.MarshalText(); err == nil {
|
||||
t.Error("MarshalText of the zero Namespace succeeded, want an error")
|
||||
}
|
||||
if b, err := json.Marshal(struct{ NS Namespace }{}); err == nil {
|
||||
t.Errorf("json.Marshal of a zero Namespace gave %s, want an error", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustPanics(t *testing.T) {
|
||||
for name, fn := range map[string]func(){
|
||||
"MustOrg": func() { MustOrg("../etc") },
|
||||
"MustUser": func() { MustUser("") },
|
||||
"MustRepo": func() { MustRepo("a/b") },
|
||||
"MustGroup": func() { MustGroup(".hidden") },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("did not panic")
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkOrg(b *testing.B) {
|
||||
for b.Loop() {
|
||||
if _, err := Org("01hxyz9k7mq4"); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkParse(b *testing.B) {
|
||||
for b.Loop() {
|
||||
if _, err := Parse("repo/01hxyz9k7mq4/issues"); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user