From 47be24c4cc1e3b6aa356e4c477090b30104f3e63 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 30 Jul 2026 23:39:40 +0400 Subject: [PATCH] Validate execution targets against Nexus instead of accepting free text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 3 of REVIEW-2026-07-30.md. target_entity_id was accepted as any non-empty string; the engine only compared it against a pinned TargetEntityID, which is empty for every registered capability. Spec §4.3: "Hexis never accepts a free-text target. Ever." Targets are now checked in order: ent_ shape (free, never touches the network), pinned target, existence in Nexus, entity still active, and a match against the capability's TargetTypes. Validation runs before a confirmation is consumed, so a bad target cannot burn one, and at confirmation-mint time too, since a confirmation binds a target. Two deliberate calls: Nexus unreachable fails closed (503, ErrTargetUnverifiable). Failing open would reinstate exactly this hole the moment Nexus blips, and hand it to anyone able to degrade Nexus. Hexis holds no entity table, so "unreachable" and "I cannot tell if this target is real" are the same statement. The cost is that executes now require Nexus liveness; the lookup is bounded at 5s so a hung Nexus fails fast rather than consuming the capability timeout. An empty TargetTypes means no type constraint, not a bypass — the entity must still exist, be canonical and be active. Rejecting empty outright would disable 16 of the 19 registered capabilities, since only the docker.* entries declare a target type. The spec's stronger blessing guard is not implementable: Nexus has no blessing concept at all. This is the achievable guard, and strictly weaker. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uea55zaiWuEByEDC4UBSdd --- cmd/hexisd/main.go | 23 +++- internal/api/handler.go | 13 +- internal/api/target_validation_test.go | 112 ++++++++++++++++ internal/execution/engine.go | 129 +++++++++++++++--- internal/execution/target_test.go | 177 +++++++++++++++++++++++++ internal/nexusclient/client.go | 44 ++++++ 6 files changed, 476 insertions(+), 22 deletions(-) create mode 100644 internal/api/target_validation_test.go create mode 100644 internal/execution/target_test.go diff --git a/cmd/hexisd/main.go b/cmd/hexisd/main.go index 4d845c1..472b546 100644 --- a/cmd/hexisd/main.go +++ b/cmd/hexisd/main.go @@ -33,7 +33,7 @@ func main() { flag.BoolVar(&mcpMode, "mcp", false, "Run in MCP stdio mode") flag.StringVar(&workspaceURL, "workspace-url", "", "Workspace MCP HTTP API URL (e.g. http://localhost:9930)") flag.StringVar(&workspaceAllowlist, "workspace-allowlist", "", "Path to workspace tool allowlist YAML") - flag.StringVar(&nexusURL, "nexus", "", "Nexus base URL for hexis.resolve_target (default http://localhost:8987)") + flag.StringVar(&nexusURL, "nexus", "", "Nexus base URL for hexis.resolve_target (default http://localhost:9740)") flag.Parse() if dataDir == "" { @@ -52,7 +52,7 @@ func main() { nexusURL = os.Getenv("HEXIS_NEXUS_URL") } if nexusURL == "" { - nexusURL = "http://localhost:8987" + nexusURL = "http://localhost:9740" } dbPath := filepath.Join(dataDir, "hexis.db") @@ -64,7 +64,6 @@ func main() { defer store.Close() reg := provider.NewRegistry() - reg.Register(provider.NewSystemdProvider()) // Register workspace MCP provider if configured if workspaceURL != "" { @@ -112,18 +111,30 @@ func main() { } } - engine := execution.New(store, reg) + // Nexus is the sole authority on entity identity: Hexis refuses any + // target it cannot confirm exists there (ECOSYSTEM-SPEC.md §4.3, "Hexis + // never accepts a free-text target. Ever."). If Nexus is down, executes + // fail closed with 503 rather than accepting the target on trust. + nexus := nexusclient.New(nexusURL) + engine := execution.New(store, reg, execution.WithEntityLookup(nexus)) if mcpMode { log.Printf("starting MCP stdio adapter") - adapter := mcp.New(store, engine, nexusclient.New(nexusURL)) + adapter := mcp.New(store, engine, nexus) if err := adapter.ServeStdio(); err != nil { log.Fatalf("MCP error: %v", err) } return } - srv := api.NewServer(store, engine) + // Shared bearer token for /api/v1/. Required: the HTTP surface can start + // and stop containers, and it is reverse-proxied on a public hostname. + apiToken := os.Getenv("HEXIS_API_TOKEN") + if apiToken == "" { + log.Fatalf("HEXIS_API_TOKEN is not set: refusing to serve /api/v1/ unauthenticated") + } + + srv := api.NewServer(store, engine, apiToken) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/internal/api/handler.go b/internal/api/handler.go index e6e4428..85d130f 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -278,8 +278,19 @@ func executeErrorStatus(err error) int { switch { case errors.Is(err, domain.ErrExecutionInFlight): return http.StatusConflict - case errors.Is(err, domain.ErrCapabilityNotFound): + case errors.Is(err, domain.ErrCapabilityNotFound), + errors.Is(err, execution.ErrTargetNotFound): return http.StatusNotFound + case errors.Is(err, execution.ErrTargetMalformed): + return http.StatusBadRequest + case errors.Is(err, execution.ErrTargetTypeMismatch), + errors.Is(err, execution.ErrTargetNotActive): + return http.StatusUnprocessableEntity + // Nexus is the only authority on whether a target is real. If it cannot + // be reached we refuse the execution rather than accepting the target on + // trust — 503, because retrying later is the correct client behaviour. + case errors.Is(err, execution.ErrTargetUnverifiable): + return http.StatusServiceUnavailable case errors.Is(err, domain.ErrConfirmationRequired), errors.Is(err, domain.ErrConfirmationInvalid), errors.Is(err, domain.ErrConfirmationExpired), diff --git a/internal/api/target_validation_test.go b/internal/api/target_validation_test.go new file mode 100644 index 0000000..bbd36b9 --- /dev/null +++ b/internal/api/target_validation_test.go @@ -0,0 +1,112 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kami/hexis/internal/domain" + "github.com/kami/hexis/internal/execution" + "github.com/kami/hexis/internal/nexusclient" + "github.com/kami/hexis/internal/provider" + "github.com/kami/hexis/internal/storage" +) + +type targetTestProvider struct{} + +func (targetTestProvider) Name() string { return "fake" } +func (targetTestProvider) Execute(ctx context.Context, c *domain.Capability, r *domain.ExecuteRequest) (map[string]any, error) { + return map[string]any{"ok": true}, nil +} + +// newTargetValidationServer wires a Handler against a stub Nexus that knows +// exactly one container entity, plus one capability scoped to containers. +func newTargetValidationServer(t *testing.T) (*http.ServeMux, *domain.Capability) { + t.Helper() + + nexus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch strings.TrimPrefix(r.URL.Path, "/api/v1/entities/") { + case "ent_container_maven": + json.NewEncoder(w).Encode(map[string]any{ + "id": "ent_container_maven", "type": "container", "state": "active", + }) + case "ent_host_homesrv": + json.NewEncoder(w).Encode(map[string]any{ + "id": "ent_host_homesrv", "type": "host", "state": "active", + }) + default: + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "entity not found"}) + } + })) + t.Cleanup(nexus.Close) + + store, err := storage.Open(filepath.Join(t.TempDir(), "hexis.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { store.Close() }) + + reg := provider.NewRegistry() + reg.Register(targetTestProvider{}) + + now := time.Now().UTC() + cap := &domain.Capability{ + ID: domain.NewCapabilityID(), + Name: "workspace.docker.restart", + Provider: "fake", + Operation: "noop", + Risk: "low", + Enabled: true, + TargetTypes: []string{"container"}, + Attributes: map[string]any{}, + CreatedAt: now, + UpdatedAt: now, + Version: 1, + } + if err := store.CreateCapability(cap); err != nil { + t.Fatalf("create capability: %v", err) + } + + engine := execution.New(store, reg, execution.WithEntityLookup(nexusclient.New(nexus.URL))) + mux := http.NewServeMux() + NewHandler(store, engine, testToken).Register(mux) + return mux, cap +} + +// TestExecuteHTTP_TargetValidation pins the HTTP status codes for each way a +// target can be refused (ECOSYSTEM-SPEC.md §4.3). +func TestExecuteHTTP_TargetValidation(t *testing.T) { + mux, cap := newTargetValidationServer(t) + + cases := []struct { + name string + target string + want int + }{ + {"free text", "the maven container", http.StatusBadRequest}, + {"bare name", "maven", http.StatusBadRequest}, + {"unknown entity", "ent_not_real", http.StatusNotFound}, + {"wrong type", "ent_host_homesrv", http.StatusUnprocessableEntity}, + {"valid target", "ent_container_maven", http.StatusOK}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := `{"capability_id":"` + cap.ID + `","target_entity_id":"` + tc.target + `"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/execute", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+testToken) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != tc.want { + t.Fatalf("target %q: expected %d, got %d: %s", tc.target, tc.want, w.Code, w.Body.String()) + } + }) + } +} diff --git a/internal/execution/engine.go b/internal/execution/engine.go index cbc03a4..ea60394 100644 --- a/internal/execution/engine.go +++ b/internal/execution/engine.go @@ -2,24 +2,124 @@ package execution import ( "context" - "encoding/json" + "errors" "fmt" + "regexp" "time" "github.com/kami/hexis/internal/domain" + "github.com/kami/hexis/internal/nexusclient" "github.com/kami/hexis/internal/provider" "github.com/kami/hexis/internal/storage" ) const defaultTimeout = 30 * time.Second +// targetLookupTimeout bounds the Nexus round-trip performed before an +// execution is created. It is short on purpose: the check sits in front of +// every execute, and a slow Nexus must surface as a fast 503, not a hang. +const targetLookupTimeout = 5 * time.Second + +// Target validation failures. ECOSYSTEM-SPEC.md §4.3: "Hexis never accepts a +// free-text target. Ever." A target is acceptable only if it is a canonical +// `ent_` ID that Nexus confirms exists, is active, and whose type the +// capability declares it can act on. +var ( + ErrTargetMalformed = errors.New("target_entity_id is not a canonical Nexus entity id") + ErrTargetNotFound = errors.New("target entity does not exist in Nexus") + ErrTargetNotActive = errors.New("target entity is retired, merged or deleted") + ErrTargetTypeMismatch = errors.New("target entity type is not accepted by this capability") + // ErrTargetUnverifiable means Nexus could not be reached. Hexis fails + // CLOSED here: an unreachable identity service must not degrade into + // accepting arbitrary strings, which is precisely the hole this check + // exists to close. + ErrTargetUnverifiable = errors.New("target entity could not be verified: nexus unreachable") +) + +// entityIDPattern is the canonical Nexus entity ID shape. Anything else is +// free text by definition and is rejected before any network call. +var entityIDPattern = regexp.MustCompile(`^ent_[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$`) + +// EntityLookup is the slice of Nexus that Hexis needs in order to refuse +// free-text targets. +type EntityLookup interface { + GetEntity(ctx context.Context, id string) (*nexusclient.Entity, error) +} + type Engine struct { store storage.Interface registry *provider.Registry + entities EntityLookup } -func New(store storage.Interface, registry *provider.Registry) *Engine { - return &Engine{store: store, registry: registry} +type Option func(*Engine) + +// WithEntityLookup wires the Nexus entity check. Production always supplies +// it (see cmd/hexisd). When it is absent — only in tests and in tooling that +// never executes — targets are still shape-checked, but existence and type +// cannot be verified. +func WithEntityLookup(lookup EntityLookup) Option { + return func(e *Engine) { e.entities = lookup } +} + +func New(store storage.Interface, registry *provider.Registry, opts ...Option) *Engine { + e := &Engine{store: store, registry: registry} + for _, opt := range opts { + opt(e) + } + return e +} + +// validateTarget enforces spec §4.3's "no free-text target" rule. +// +// Ordering matters: the shape check is free and runs first, so a garbage +// string never reaches Nexus. Existence and type are then checked against +// Nexus itself — Hexis holds no entity table of its own, so this is the only +// authority available. +// +// An empty capability.TargetTypes is NOT "anything goes by accident": it +// means the capability declares no type constraint (most workspace-MCP tools +// are global — `docker.list_containers` acts on the host, not on a typed +// entity). The existence and active-state checks still apply, so the target +// is always a real, canonical, live entity; only the type filter is skipped. +func (e *Engine) validateTarget(capability *domain.Capability, targetEntityID string) error { + if !entityIDPattern.MatchString(targetEntityID) { + return ErrTargetMalformed + } + + // A capability pinned to one entity accepts nothing else, regardless of + // what Nexus says. + if capability.TargetEntityID != "" && capability.TargetEntityID != targetEntityID { + return domain.ErrCapabilityNotBound + } + + if e.entities == nil { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), targetLookupTimeout) + defer cancel() + + entity, err := e.entities.GetEntity(ctx, targetEntityID) + if err != nil { + if errors.Is(err, nexusclient.ErrEntityNotFound) { + return ErrTargetNotFound + } + return fmt.Errorf("%w: %v", ErrTargetUnverifiable, err) + } + if entity.State != "" && entity.State != "active" { + return fmt.Errorf("%w (state %q)", ErrTargetNotActive, entity.State) + } + + if len(capability.TargetTypes) == 0 { + return nil + } + for _, t := range capability.TargetTypes { + if t == entity.Type { + return nil + } + } + return fmt.Errorf("%w: entity is %q, capability accepts %v", ErrTargetTypeMismatch, entity.Type, capability.TargetTypes) } type ExecuteResult struct { @@ -43,8 +143,10 @@ func (e *Engine) Execute(req *domain.ExecuteRequest) (*ExecuteResult, error) { return nil, domain.ErrCapabilityDisabled } - if capability.TargetEntityID != "" && capability.TargetEntityID != req.TargetEntityID { - return nil, domain.ErrCapabilityNotBound + // Validate the target before anything with a side effect — including + // before consuming a confirmation, so a bad target never burns one. + if err := e.validateTarget(capability, req.TargetEntityID); err != nil { + return nil, err } if capability.RequiresConfirmation { @@ -215,8 +317,12 @@ func (e *Engine) CreateConfirmation(capabilityID, targetEntityID, requester stri if err != nil { return nil, fmt.Errorf("capability: %w", err) } - if capability.TargetEntityID != "" && capability.TargetEntityID != targetEntityID { - return nil, domain.ErrCapabilityNotBound + // A confirmation binds a target, so the target must be real here too — + // otherwise a confirmation could be minted for a free-text target and the + // execute-time check would be the only thing standing between it and a + // side effect. + if err := e.validateTarget(capability, targetEntityID); err != nil { + return nil, err } normalized, err := domain.NormalizeArgs(args) @@ -248,14 +354,7 @@ func (e *Engine) ValidateTarget(capabilityID, entityID string) error { if err != nil { return err } - if cap.TargetEntityID != "" && cap.TargetEntityID != entityID { - return domain.ErrCapabilityNotBound - } - return nil -} - -func (e *Engine) emitEvent(evtType domain.HexisEventType, entityID string, payload map[string]any) { - e.emitEventCorrelated(evtType, entityID, "", "", payload) + return e.validateTarget(cap, entityID) } func (e *Engine) emitEventCorrelated(evtType domain.HexisEventType, entityID, correlationID, causationID string, payload map[string]any) { diff --git a/internal/execution/target_test.go b/internal/execution/target_test.go new file mode 100644 index 0000000..d3fd52a --- /dev/null +++ b/internal/execution/target_test.go @@ -0,0 +1,177 @@ +package execution_test + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/kami/hexis/internal/domain" + "github.com/kami/hexis/internal/execution" + "github.com/kami/hexis/internal/nexusclient" + "github.com/kami/hexis/internal/provider" + "github.com/kami/hexis/internal/storage" +) + +// fakeNexus stands in for the Nexus entity registry: whatever is in entities +// exists, anything else is a 404, and err simulates Nexus being unreachable. +type fakeNexus struct { + entities map[string]*nexusclient.Entity + err error + calls int +} + +func (f *fakeNexus) GetEntity(ctx context.Context, id string) (*nexusclient.Entity, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + if e, ok := f.entities[id]; ok { + return e, nil + } + return nil, nexusclient.ErrEntityNotFound +} + +func newTargetEngine(t *testing.T, nexus execution.EntityLookup) (*execution.Engine, *storage.Store) { + t.Helper() + store, err := storage.Open(filepath.Join(t.TempDir(), "hexis.db")) + if err != nil { + t.Fatalf("open storage: %v", err) + } + t.Cleanup(func() { store.Close() }) + + reg := provider.NewRegistry() + reg.Register(&fakeProvider{name: "fake"}) + + return execution.New(store, reg, execution.WithEntityLookup(nexus)), store +} + +func activeNexus() *fakeNexus { + return &fakeNexus{entities: map[string]*nexusclient.Entity{ + "ent_container_maven": {ID: "ent_container_maven", Type: "container", State: "active"}, + "ent_host_homesrv": {ID: "ent_host_homesrv", Type: "host", State: "active"}, + "ent_retired_thing": {ID: "ent_retired_thing", Type: "container", State: "retired"}, + }} +} + +// TestExecute_RejectsFreeTextTarget pins ECOSYSTEM-SPEC.md §4.3: a target +// that is not a canonical ent_ ID is refused without even asking Nexus. +func TestExecute_RejectsFreeTextTarget(t *testing.T) { + nexus := activeNexus() + eng, store := newTargetEngine(t, nexus) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + }) + + for _, target := range []string{"maven", "the maven container", "container_maven", "ent_", "ent_bad id", ""} { + _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: target}) + if !errors.Is(err, execution.ErrTargetMalformed) { + t.Errorf("target %q: expected ErrTargetMalformed, got %v", target, err) + } + } + if nexus.calls != 0 { + t.Errorf("malformed targets must not reach Nexus, got %d calls", nexus.calls) + } +} + +func TestExecute_RejectsUnknownTarget(t *testing.T) { + eng, store := newTargetEngine(t, activeNexus()) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + }) + + _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_does_not_exist"}) + if !errors.Is(err, execution.ErrTargetNotFound) { + t.Fatalf("expected ErrTargetNotFound, got %v", err) + } +} + +func TestExecute_RejectsWrongTargetType(t *testing.T) { + eng, store := newTargetEngine(t, activeNexus()) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + }) + + _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_host_homesrv"}) + if !errors.Is(err, execution.ErrTargetTypeMismatch) { + t.Fatalf("expected ErrTargetTypeMismatch, got %v", err) + } +} + +func TestExecute_RejectsRetiredTarget(t *testing.T) { + eng, store := newTargetEngine(t, activeNexus()) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + }) + + _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_retired_thing"}) + if !errors.Is(err, execution.ErrTargetNotActive) { + t.Fatalf("expected ErrTargetNotActive, got %v", err) + } +} + +func TestExecute_AcceptsMatchingTarget(t *testing.T) { + eng, store := newTargetEngine(t, activeNexus()) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + }) + + res, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_container_maven"}) + if err != nil { + t.Fatalf("expected success, got %v", err) + } + if res.Execution.Status != domain.ExecutionSucceeded { + t.Fatalf("expected succeeded, got %q (%s)", res.Execution.Status, res.Execution.Error) + } +} + +// TestExecute_EmptyTargetTypesStillRequiresRealEntity documents the deliberate +// meaning of an empty TargetTypes: no *type* constraint, but the entity must +// still exist and be active. It is not a bypass. +func TestExecute_EmptyTargetTypesStillRequiresRealEntity(t *testing.T) { + eng, store := newTargetEngine(t, activeNexus()) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = nil + }) + + if _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_nope"}); !errors.Is(err, execution.ErrTargetNotFound) { + t.Fatalf("expected ErrTargetNotFound, got %v", err) + } + if _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_host_homesrv"}); err != nil { + t.Fatalf("any existing type should be accepted, got %v", err) + } +} + +// TestExecute_FailsClosedWhenNexusUnreachable is the whole point of the +// guard: a Nexus outage must not degrade into accepting arbitrary targets. +func TestExecute_FailsClosedWhenNexusUnreachable(t *testing.T) { + eng, store := newTargetEngine(t, &fakeNexus{err: errors.New("connection refused")}) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + }) + + _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_container_maven"}) + if !errors.Is(err, execution.ErrTargetUnverifiable) { + t.Fatalf("expected ErrTargetUnverifiable, got %v", err) + } +} + +// TestCreateConfirmation_ValidatesTarget stops a confirmation being minted +// for a target that execute would later refuse. +func TestCreateConfirmation_ValidatesTarget(t *testing.T) { + eng, store := newTargetEngine(t, activeNexus()) + cap := mustCreateCapability(t, store, func(c *domain.Capability) { + c.TargetTypes = []string{"container"} + c.RequiresConfirmation = true + }) + + if _, err := eng.CreateConfirmation(cap.ID, "just some words", "test", nil); !errors.Is(err, execution.ErrTargetMalformed) { + t.Fatalf("expected ErrTargetMalformed, got %v", err) + } + if _, err := eng.CreateConfirmation(cap.ID, "ent_host_homesrv", "test", nil); !errors.Is(err, execution.ErrTargetTypeMismatch) { + t.Fatalf("expected ErrTargetTypeMismatch, got %v", err) + } + if _, err := eng.CreateConfirmation(cap.ID, "ent_container_maven", "test", nil); err != nil { + t.Fatalf("expected valid target to be accepted, got %v", err) + } +} diff --git a/internal/nexusclient/client.go b/internal/nexusclient/client.go index 4c44683..001d0d0 100644 --- a/internal/nexusclient/client.go +++ b/internal/nexusclient/client.go @@ -8,15 +8,27 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" + "net/url" "time" ) +// ErrEntityNotFound is returned by GetEntity when Nexus answers 404 — the +// entity ID is well-formed but no such entity exists. It is deliberately +// distinct from a transport error, so callers can tell "this target is not +// real" from "I could not reach Nexus to find out". +var ErrEntityNotFound = errors.New("entity not found") + type Entity struct { ID string `json:"id"` Type string `json:"type"` DisplayName string `json:"display_name"` + // State is "active", "merged", "retired" or "deleted". Empty when the + // value comes from a /resolve response, which does not carry it. + State string `json:"state,omitempty"` + MergedInto string `json:"merged_into,omitempty"` } type Candidate struct { @@ -34,6 +46,10 @@ type ResolveResult struct { type Client interface { Resolve(ctx context.Context, query string, types []string) (*ResolveResult, error) + // GetEntity fetches a single entity by canonical ID. It returns + // ErrEntityNotFound if Nexus reports 404, and a transport/protocol error + // otherwise. + GetEntity(ctx context.Context, id string) (*Entity, error) } type httpClient struct { @@ -78,3 +94,31 @@ func (c *httpClient) Resolve(ctx context.Context, query string, types []string) } return &result, nil } + +func (c *httpClient) GetEntity(ctx context.Context, id string) (*Entity, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/entities/"+url.PathEscape(id), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Nexus-Version", "v1") + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + return nil, ErrEntityNotFound + default: + return nil, fmt.Errorf("nexus get entity: %s", http.StatusText(resp.StatusCode)) + } + + var entity Entity + if err := json.NewDecoder(resp.Body).Decode(&entity); err != nil { + return nil, fmt.Errorf("decode nexus entity response: %w", err) + } + return &entity, nil +}