Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,30 @@ without bumping the spec-version constant.

## [Unreleased]

- Repository bootstrap: `go.mod`, license, contributor docs, CI
scaffolding. No public API yet.
## [0.2.0]

### Changed

- **Breaking:** `Parse` now returns Subject Identifier values in their
value form (e.g. `IssSubID`) rather than pointer form (`*IssSubID`),
so a value built as a struct literal and a value read back from
`Parse` share the same dynamic type. Code that type-asserts or
type-switches on a `Parse` result must match the value forms. The
`SubjectIdentifier` and `Parse` documentation state this canonical
form, and extension types registered via `RegisterFormat` must
satisfy the interface with value receivers.

### Fixed

- `AliasesID.Validate` now rejects a nested `aliases` identifier
regardless of how it was produced. A nested aliases parsed from JSON
previously escaped the `ErrNestedAliases` check; a hand-built one was
already caught.

## [0.1.0]

- Initial release: the eight RFC 9493 Subject Identifier formats, a
discriminator-driven JSON codec (`Parse` plus spec-order, byte-stable
`Marshal`), opt-in per-format `Validate` with sentinel errors, a
forward-compatible `UnknownFormat` carrier that preserves unknown
wire bytes verbatim, and the `RegisterFormat` extension hook.
6 changes: 5 additions & 1 deletion aliases.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ func (a AliasesID) Validate() error {
}
errs := make([]error, 0, len(a.Identifiers))
for _, id := range a.Identifiers {
if _, ok := id.(AliasesID); ok {
// Detect nesting by the format discriminator rather than a
// concrete-type assertion: an inner aliases identifier parsed
// from the wire and one built as a Go literal must both be
// caught regardless of their dynamic pointer/value form.
if id.Format() == "aliases" {
errs = append(errs, ErrNestedAliases)
continue
}
Expand Down
64 changes: 64 additions & 0 deletions canonical_form_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2026 The go-subjectid Authors
// SPDX-License-Identifier: Apache-2.0

package subjectid_test

import (
"errors"
"testing"

"github.com/hstern/go-subjectid"
)

// TestParseReturnsValueForm pins the canonical-form decision:
// Parse returns the value form of an identifier, so the
// dynamic type a caller reads back from Parse is identical to the
// value literal they would write by hand (subjectid.IssSubID, not
// *subjectid.IssSubID). A direct value assertion must succeed and the
// pointer assertion must fail.
func TestParseReturnsValueForm(t *testing.T) {
id, err := subjectid.Parse([]byte(
`{"format":"iss_sub","iss":"https://idp.example.com/","sub":"alice"}`))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, ok := id.(subjectid.IssSubID); !ok {
t.Fatalf("Parse returned %T, want value form subjectid.IssSubID", id)
}
if _, ok := id.(*subjectid.IssSubID); ok {
t.Fatalf("Parse returned pointer form %T, want value-canonical", id)
}
}

// TestParseUnknownReturnsValueForm checks that the forward-compat
// UnknownFormat carrier also escapes Parse in value form, consistent
// with the canonical-form rule.
func TestParseUnknownReturnsValueForm(t *testing.T) {
id, err := subjectid.Parse([]byte(
`{"format":"org.example.unknown","opaque_id":"xyz"}`))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, ok := id.(subjectid.UnknownFormat); !ok {
t.Fatalf("Parse returned %T, want value form subjectid.UnknownFormat", id)
}
}

// TestParseNestedAliasesFromWireRejected guards the latent bug the
// pointer/value split caused: a nested aliases identifier arriving
// from the wire (which Parse populates recursively) must be rejected
// by Validate with ErrNestedAliases, exactly as a hand-built nested
// alias is. Before the canonical-form fix the value-typed nested
// check missed the pointer-form wire element and this slipped through.
func TestParseNestedAliasesFromWireRejected(t *testing.T) {
raw := []byte(`{"format":"aliases","identifiers":[` +
`{"format":"aliases","identifiers":[` +
`{"format":"email","email":"user@example.com"}]}]}`)
id, err := subjectid.Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if err := id.Validate(); !errors.Is(err, subjectid.ErrNestedAliases) {
t.Fatalf("Validate() of wire-parsed nested aliases = %v, want ErrNestedAliases", err)
}
}
2 changes: 1 addition & 1 deletion conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func TestSpecFixturesRoundTripByteStable(t *testing.T) {
if got, want := id.Format(), fx.Format; got != want {
t.Errorf("parsed Format() = %q, want %q", got, want)
}
if _, isUnknown := id.(*subjectid.UnknownFormat); isUnknown {
if _, isUnknown := id.(subjectid.UnknownFormat); isUnknown {
t.Errorf("parsed into UnknownFormat; built-in dispatch did not fire")
}
if verr := id.Validate(); verr != nil {
Expand Down
4 changes: 2 additions & 2 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func ExampleParse() {
panic(err)
}

email := id.(*subjectid.EmailID)
email := id.(subjectid.EmailID)
fmt.Println(id.Format(), email.Email)
// Output: email user@example.com
}
Expand All @@ -40,7 +40,7 @@ func ExampleParse_unknownFormat() {
panic(err)
}

unk := id.(*subjectid.UnknownFormat)
unk := id.(subjectid.UnknownFormat)
out, _ := json.Marshal(unk)
fmt.Println(unk.FormatName)
fmt.Println(string(out))
Expand Down
4 changes: 2 additions & 2 deletions extension_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@ func TestRegisterFormatExtensionSmoke(t *testing.T) {
if err != nil {
t.Fatalf("Parse: %v", err)
}
tenant, ok := id.(*orgTenantID)
tenant, ok := id.(orgTenantID)
if !ok {
t.Fatalf("Parse returned %T, want *orgTenantID", id)
t.Fatalf("Parse returned %T, want orgTenantID", id)
}
if got, want := tenant.Tenant, "acme-prod"; got != want {
t.Errorf("Tenant = %q, want %q", got, want)
Expand Down
16 changes: 7 additions & 9 deletions forward_compat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ func TestForwardCompatUnknownFormatRoundTrip(t *testing.T) {
t.Fatalf("Parse(unknown format): %v", err)
}

unk, ok := id.(*subjectid.UnknownFormat)
unk, ok := id.(subjectid.UnknownFormat)
if !ok {
t.Fatalf("Parse returned %T, want *UnknownFormat", id)
t.Fatalf("Parse returned %T, want subjectid.UnknownFormat", id)
}
if got, want := unk.Format(), "org.example.future"; got != want {
t.Errorf("Format() = %q, want %q", got, want)
Expand Down Expand Up @@ -78,19 +78,17 @@ func TestForwardCompatUnknownFormatInsideAliases(t *testing.T) {
if err != nil {
t.Fatalf("Parse(aliases with unknown inner): %v", err)
}
aliases, ok := id.(*subjectid.AliasesID)
aliases, ok := id.(subjectid.AliasesID)
if !ok {
t.Fatalf("Parse returned %T, want *AliasesID", id)
t.Fatalf("Parse returned %T, want subjectid.AliasesID", id)
}
if got, want := len(aliases.Identifiers), 2; got != want {
t.Fatalf("len(Identifiers) = %d, want %d", got, want)
}
if _, ok := aliases.Identifiers[0].(subjectid.EmailID); !ok {
if _, ok := aliases.Identifiers[0].(*subjectid.EmailID); !ok {
t.Errorf("Identifiers[0] = %T, want EmailID (value or pointer)", aliases.Identifiers[0])
}
t.Errorf("Identifiers[0] = %T, want subjectid.EmailID", aliases.Identifiers[0])
}
if _, ok := aliases.Identifiers[1].(*subjectid.UnknownFormat); !ok {
t.Errorf("Identifiers[1] = %T, want *UnknownFormat", aliases.Identifiers[1])
if _, ok := aliases.Identifiers[1].(subjectid.UnknownFormat); !ok {
t.Errorf("Identifiers[1] = %T, want subjectid.UnknownFormat", aliases.Identifiers[1])
}
}
5 changes: 3 additions & 2 deletions opaque.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ func (o OpaqueID) Validate() error {

func (OpaqueID) sealed() {}

// Compile-time assertion that OpaqueID satisfies SubjectIdentifier.
var _ SubjectIdentifier = (*OpaqueID)(nil)
// Compile-time assertion that OpaqueID satisfies SubjectIdentifier
// in value form — the canonical dynamic form Parse returns.
var _ SubjectIdentifier = OpaqueID{}
8 changes: 8 additions & 0 deletions registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ import (
//
// Constructors registered for non-built-in formats must return a
// type that embeds [Seal] so it satisfies the sealed interface.
//
// The returned value is conventionally a pointer so the codec can
// populate it via UnmarshalJSON, but [Parse] normalizes the result
// to its canonical value form before returning it. The extension
// type must therefore satisfy [SubjectIdentifier] with value
// receivers, so the dereferenced value still implements the
// interface; a type whose methods use pointer receivers would not
// survive that normalization.
type Constructor func() SubjectIdentifier

// formatRegistry is the package-global dispatch table. It is
Expand Down
8 changes: 8 additions & 0 deletions subjectid.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ const SpecVersion = "RFC 9493"
// later commit) rather than implementing the interface directly:
// registration feeds the codec dispatch table, whereas a direct
// implementation would be invisible to it.
//
// Canonical dynamic form: every value this package produces is the
// concrete value type, never a pointer to it. [Parse] returns
// [IssSubID], not *IssSubID, so the dynamic type read back from
// Parse is identical to the struct literal a caller writes by hand.
// Consumers branching with a type switch or assertion should match
// the value forms (case IssSubID, case AliasesID, …); the pointer
// forms never occur on a value obtained from this package's API.
type SubjectIdentifier interface {
// Format returns the IANA "format" discriminator for this
// identifier — "email", "iss_sub", "aliases", and so on. The
Expand Down
5 changes: 3 additions & 2 deletions unknown.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,6 @@ func (UnknownFormat) Validate() error { return nil }

func (UnknownFormat) sealed() {}

// Compile-time assertion that UnknownFormat satisfies SubjectIdentifier.
var _ SubjectIdentifier = (*UnknownFormat)(nil)
// Compile-time assertion that UnknownFormat satisfies SubjectIdentifier
// in value form — the canonical dynamic form Parse returns.
var _ SubjectIdentifier = UnknownFormat{}
41 changes: 38 additions & 3 deletions unmarshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package subjectid
import (
"encoding/json"
"fmt"
"reflect"
)

// envelope is the wire shape Parse peeks at first to discover
Expand All @@ -16,9 +17,43 @@ type envelope struct {
Format string `json:"format"`
}

// deref normalizes a freshly-parsed identifier to its canonical
// value form. Registry constructors allocate a pointer so the codec
// can populate it via UnmarshalJSON; Parse returns the dereferenced
// value so the dynamic type a caller reads back from Parse matches
// the value literal they would write by hand (subjectid.IssSubID,
// not *subjectid.IssSubID). See [SubjectIdentifier] for the
// canonical-form contract.
//
// Reflection keeps this uniform across the built-in formats and any
// extension type registered via [RegisterFormat]; the constraint it
// imposes — extension types must satisfy [SubjectIdentifier] with
// value receivers, so the dereferenced value still implements the
// interface — is documented on RegisterFormat. A non-pointer is
// returned unchanged.
//
// An extension type registered with pointer-receiver methods would
// not satisfy the interface in dereferenced form; rather than panic
// or drop it, deref returns such a value unchanged. Normalization is
// thus best-effort, and value-canonical is guaranteed only for the
// built-ins and conforming extensions.
func deref(id SubjectIdentifier) SubjectIdentifier {
rv := reflect.ValueOf(id)
if rv.Kind() != reflect.Pointer {
return id
}
if v, ok := rv.Elem().Interface().(SubjectIdentifier); ok {
return v
}
return id
}

// Parse decodes a Subject Identifier from raw JSON, dispatching
// on the value of the "format" member to the appropriate
// concrete type:
// concrete type. The returned identifier is always in value form
// (e.g. [IssSubID], never *IssSubID) — the canonical dynamic form
// for every value this package produces, matching what a caller
// constructs as a struct literal. See [SubjectIdentifier].
//
// - For a built-in format (account, email, iss_sub, opaque,
// phone_number, did, uri, aliases), the matching per-format
Expand Down Expand Up @@ -57,13 +92,13 @@ func Parse(raw json.RawMessage) (SubjectIdentifier, error) {
if err := json.Unmarshal(raw, target); err != nil {
return nil, err
}
return target, nil
return deref(target), nil
}
// Unknown format — copy the bytes so the caller cannot mutate
// our internal state by holding a reference to raw.
bytes := make(json.RawMessage, len(raw))
copy(bytes, raw)
return &UnknownFormat{
return UnknownFormat{
FormatName: env.Format,
Raw: bytes,
}, nil
Expand Down
Loading
Loading