Files

262 lines
5.5 KiB
Go

package domain
import (
"errors"
"sort"
"strings"
)
var ErrBadIdentifier = errors.New("identifier: invalid format")
// Identifier is an immutable canonical key.
// Canonical form: "namespace::tag1[] . tag2[k=v;foo=bar] . tag3[]"
type Identifier struct{ key string }
// NewIdentifier builds a canonical key with strict validation.
// Tags and param keys are sorted. Tags with no params emit "name[]".
// Rejects on: empty namespace, bad tag names, bad keys/values.
func NewIdentifier(namespace string, tags map[string]map[string]string) (Identifier, error) {
ns := strings.TrimSpace(namespace)
if !validNamespace(ns) {
return Identifier{}, ErrBadIdentifier
}
// Validate and copy to protect immutability and reject dup keys early.
clean := make(map[string]map[string]string, len(tags))
for name, params := range tags {
n := strings.TrimSpace(name)
if !validIDTagName(n) {
return Identifier{}, ErrBadIdentifier
}
if _, exists := clean[n]; exists {
// impossible via map input, but keep the intent explicit
return Identifier{}, ErrBadIdentifier
}
if len(params) == 0 {
clean[n] = map[string]string{}
continue
}
dst := make(map[string]string, len(params))
for k, v := range params {
kk := strings.TrimSpace(k)
vv := strings.TrimSpace(v)
if !validParamKey(kk) || !validIDParamValue(vv) {
return Identifier{}, ErrBadIdentifier
}
if _, dup := dst[kk]; dup {
return Identifier{}, ErrBadIdentifier
}
dst[kk] = vv
}
clean[n] = dst
}
var b strings.Builder
// Rough capacity hint.
b.Grow(len(ns) + 2 + 16*len(clean) + 32)
// namespace
b.WriteString(ns)
b.WriteString("::")
// stable tag order
names := make([]string, 0, len(clean))
for n := range clean {
names = append(names, n)
}
sort.Strings(names)
for i, name := range names {
if i > 0 {
b.WriteByte('.')
}
b.WriteString(name)
params := clean[name]
if len(params) == 0 {
b.WriteString("[]")
continue
}
// stable param order
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
b.WriteByte('[')
for j, k := range keys {
if j > 0 {
b.WriteByte(';')
}
b.WriteString(k)
b.WriteByte('=')
b.WriteString(params[k])
}
b.WriteByte(']')
}
return Identifier{key: b.String()}, nil
}
// NewIdentifierFromRaw wraps a raw key without validation.
func NewIdentifierFromRaw(raw string) Identifier { return Identifier{key: raw} }
// Key returns the canonical key string.
func (id Identifier) Key() string { return id.key }
// Parse returns namespace and tags from Key.
// Accepts "tag" (bare) as "tag[]". Emits "name[]"/"[k=v;...]". First token wins on duplicates.
func (id Identifier) Parse() (string, map[string]map[string]string, error) {
k := id.key
// namespace
i := strings.Index(k, "::")
if i <= 0 {
return "", nil, ErrBadIdentifier
}
ns := strings.TrimSpace(k[:i])
if !validNamespace(ns) {
return "", nil, ErrBadIdentifier
}
raw := k[i+2:]
tags := make(map[string]map[string]string, 8)
if raw == "" {
return ns, tags, nil
}
for tok := range strings.SplitSeq(raw, ".") {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
lb := strings.IndexByte(tok, '[')
if lb == -1 {
// bare tag => empty params
name := strings.TrimSpace(tok)
if !validIDTagName(name) {
return "", nil, ErrBadIdentifier
}
if _, exists := tags[name]; !exists {
tags[name] = map[string]string{}
}
continue
}
rb := strings.LastIndexByte(tok, ']')
if rb == -1 || rb < lb || rb != len(tok)-1 {
return "", nil, ErrBadIdentifier
}
name := strings.TrimSpace(tok[:lb])
if !validIDTagName(name) {
return "", nil, ErrBadIdentifier
}
// first tag wins
if _, exists := tags[name]; exists {
continue
}
body := tok[lb+1 : rb]
// forbid outer whitespace like "[ x=1 ]"
if body != strings.TrimSpace(body) {
return "", nil, ErrBadIdentifier
}
if body == "" {
tags[name] = map[string]string{}
continue
}
// parse "k=v;foo=bar"
params := make(map[string]string, 4)
for pair := range strings.SplitSeq(body, ";") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
kv := strings.SplitN(pair, "=", 2)
if len(kv) != 2 {
return "", nil, ErrBadIdentifier
}
key := strings.TrimSpace(kv[0])
val := strings.TrimSpace(kv[1])
if !validParamKey(key) || !validIDParamValue(val) || val == "" {
return "", nil, ErrBadIdentifier
}
// first key wins
if _, dup := params[key]; !dup {
params[key] = val
}
}
tags[name] = params
}
return ns, tags, nil
}
// --- validation helpers ---
func validNamespace(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch r {
case '[', ']', ':':
return false
}
if isSpace(r) {
return false
}
}
return true
}
func validIDTagName(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch r {
case '[', ']', '.', ':': // added ':'
return false
}
if isSpace(r) {
return false
}
}
return true
}
func validParamKey(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch r {
case '[', ']', ';', '=':
return false
}
if isSpace(r) {
return false
}
}
return true
}
func validIDParamValue(s string) bool {
// allow spaces; forbid only bracket, pair, and kv delimiters
for _, r := range s {
switch r {
case '[', ']', ';', '=':
return false
}
}
return true
}
func isSpace(r rune) bool { return r == ' ' || r == '\t' || r == '\n' || r == '\r' }