| 1 | // Package extensioncontract holds the leaf capability identity types shared by |
| 2 | // the plugin manifest parser and the extension kernel. It must stay free of |
| 3 | // business imports so both layers can depend on it without cycles. |
| 4 | package extensioncontract |
| 5 | |
| 6 | import ( |
| 7 | "crypto/sha256" |
| 8 | "encoding/hex" |
| 9 | "encoding/json" |
| 10 | "fmt" |
| 11 | "strings" |
| 12 | |
| 13 | "golang.org/x/mod/semver" |
| 14 | ) |
| 15 | |
| 16 | // CapabilityKey is a namespaced capability identity. Namespace + Kind + ID |
| 17 | // form the stable address used by dependency resolution and conflict reports. |
| 18 | type CapabilityKey struct { |
| 19 | Namespace string `json:"namespace"` |
| 20 | Kind string `json:"kind"` |
| 21 | ID string `json:"id"` |
| 22 | } |
| 23 | |
| 24 | // String returns the canonical wire form namespace/kind/id. |
| 25 | func (k CapabilityKey) String() string { |
| 26 | return k.Namespace + "/" + k.Kind + "/" + k.ID |
| 27 | } |
| 28 | |
| 29 | // Validate rejects empty or whitespace-bearing key parts. |
| 30 | func (k CapabilityKey) Validate() error { |
| 31 | if strings.TrimSpace(k.Namespace) == "" { |
| 32 | return fmt.Errorf("capability namespace is required") |
| 33 | } |
| 34 | if strings.TrimSpace(k.Kind) == "" { |
| 35 | return fmt.Errorf("capability kind is required") |
| 36 | } |
| 37 | if strings.TrimSpace(k.ID) == "" { |
| 38 | return fmt.Errorf("capability id is required") |
| 39 | } |
| 40 | if strings.ContainsAny(k.Namespace, " \t\n") || strings.ContainsAny(k.Kind, " \t\n") || strings.ContainsAny(k.ID, " \t\n") { |
| 41 | return fmt.Errorf("capability key parts must not contain whitespace") |
| 42 | } |
| 43 | return nil |
| 44 | } |
| 45 | |
| 46 | // Capability is a concrete provided capability with a version and optional |
| 47 | // schema hash. Provider, tool, and UI capabilities require a stable schema hash. |
| 48 | type Capability struct { |
| 49 | Key CapabilityKey `json:"key"` |
| 50 | Version string `json:"version"` |
| 51 | SchemaHash string `json:"schemaHash,omitempty"` |
| 52 | } |
| 53 | |
| 54 | // Validate checks key shape and version/schema rules for the capability kind. |
| 55 | func (c Capability) Validate() error { |
| 56 | if err := c.Key.Validate(); err != nil { |
| 57 | return err |
| 58 | } |
| 59 | if !semver.IsValid(normalizeVersion(c.Version)) { |
| 60 | return fmt.Errorf("capability %s: invalid version %q", c.Key, c.Version) |
| 61 | } |
| 62 | if requiresSchemaHash(c.Key.Kind) && strings.TrimSpace(c.SchemaHash) == "" { |
| 63 | return fmt.Errorf("capability %s: schemaHash is required for kind %q", c.Key, c.Key.Kind) |
| 64 | } |
| 65 | return nil |
| 66 | } |
| 67 | |
| 68 | // CanonicalHash fingerprints the capability identity used by epochs and |
| 69 | // snapshot cache metadata. |
| 70 | func (c Capability) CanonicalHash() string { |
| 71 | type wire struct { |
| 72 | Namespace string `json:"namespace"` |
| 73 | Kind string `json:"kind"` |
| 74 | ID string `json:"id"` |
| 75 | Version string `json:"version"` |
| 76 | SchemaHash string `json:"schemaHash"` |
| 77 | } |
| 78 | raw, err := json.Marshal(wire{ |
| 79 | Namespace: c.Key.Namespace, |
| 80 | Kind: c.Key.Kind, |
| 81 | ID: c.Key.ID, |
| 82 | Version: normalizeVersion(c.Version), |
| 83 | SchemaHash: strings.TrimSpace(c.SchemaHash), |
| 84 | }) |
| 85 | if err != nil { |
| 86 | // json.Marshal only fails on unsupported types; wire is plain strings. |
| 87 | return "" |
| 88 | } |
| 89 | sum := sha256.Sum256(raw) |
| 90 | return "sha256:" + hex.EncodeToString(sum[:]) |
| 91 | } |
| 92 | |
| 93 | // Requirement is a dependency on a capability, with an optional version range. |
| 94 | type Requirement struct { |
| 95 | Capability |
| 96 | VersionRange string `json:"versionRange,omitempty"` |
| 97 | Optional bool `json:"optional,omitempty"` |
| 98 | } |
| 99 | |
| 100 | // Validate checks the requirement identity and, when set, the version range. |
| 101 | func (r Requirement) Validate() error { |
| 102 | if err := r.Key.Validate(); err != nil { |
| 103 | return err |
| 104 | } |
| 105 | if v := strings.TrimSpace(r.Version); v != "" && !semver.IsValid(normalizeVersion(v)) { |
| 106 | return fmt.Errorf("requirement %s: invalid version %q", r.Key, r.Version) |
| 107 | } |
| 108 | if rangeExpr := strings.TrimSpace(r.VersionRange); rangeExpr != "" { |
| 109 | if err := validateVersionRange(rangeExpr); err != nil { |
| 110 | return fmt.Errorf("requirement %s: %w", r.Key, err) |
| 111 | } |
| 112 | } |
| 113 | return nil |
| 114 | } |
| 115 | |
| 116 | // SatisfiedBy reports whether provided meets this requirement (key match, |
| 117 | // optional schema hash pin, and semver range / exact version). |
| 118 | func (r Requirement) SatisfiedBy(provided Capability) bool { |
| 119 | if r.Key != provided.Key { |
| 120 | return false |
| 121 | } |
| 122 | if pin := strings.TrimSpace(r.SchemaHash); pin != "" && pin != strings.TrimSpace(provided.SchemaHash) { |
| 123 | return false |
| 124 | } |
| 125 | pv := normalizeVersion(provided.Version) |
| 126 | if !semver.IsValid(pv) { |
| 127 | return false |
| 128 | } |
| 129 | if exact := strings.TrimSpace(r.Version); exact != "" { |
| 130 | return semver.Compare(pv, normalizeVersion(exact)) == 0 |
| 131 | } |
| 132 | rangeExpr := strings.TrimSpace(r.VersionRange) |
| 133 | if rangeExpr == "" { |
| 134 | return true |
| 135 | } |
| 136 | return matchVersionRange(rangeExpr, pv) |
| 137 | } |
| 138 | |
| 139 | func requiresSchemaHash(kind string) bool { |
| 140 | switch strings.ToLower(strings.TrimSpace(kind)) { |
| 141 | case "provider", "tool", "ui", "uiaction": |
| 142 | return true |
| 143 | default: |
| 144 | return false |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // normalizeVersion ensures a leading "v" for golang.org/x/mod/semver. |
| 149 | func normalizeVersion(v string) string { |
| 150 | v = strings.TrimSpace(v) |
| 151 | if v == "" { |
| 152 | return "" |
| 153 | } |
| 154 | if strings.HasPrefix(v, "v") || strings.HasPrefix(v, "V") { |
| 155 | return "v" + strings.TrimPrefix(strings.TrimPrefix(v, "v"), "V") |
| 156 | } |
| 157 | return "v" + v |
| 158 | } |
| 159 | |
| 160 | // validateVersionRange accepts a simple comma-separated set of comparisons |
| 161 | // such as ">=1.0.0", ">=1.0.0,<2.0.0". |
| 162 | func validateVersionRange(expr string) error { |
| 163 | for part := range strings.SplitSeq(expr, ",") { |
| 164 | part = strings.TrimSpace(part) |
| 165 | if part == "" { |
| 166 | return fmt.Errorf("empty version range clause") |
| 167 | } |
| 168 | op, ver, ok := splitRangeClause(part) |
| 169 | if !ok { |
| 170 | return fmt.Errorf("invalid version range clause %q", part) |
| 171 | } |
| 172 | if op == "" || !semver.IsValid(normalizeVersion(ver)) { |
| 173 | return fmt.Errorf("invalid version range clause %q", part) |
| 174 | } |
| 175 | } |
| 176 | return nil |
| 177 | } |
| 178 | |
| 179 | func matchVersionRange(expr, version string) bool { |
| 180 | for part := range strings.SplitSeq(expr, ",") { |
| 181 | part = strings.TrimSpace(part) |
| 182 | op, ver, ok := splitRangeClause(part) |
| 183 | if !ok { |
| 184 | return false |
| 185 | } |
| 186 | target := normalizeVersion(ver) |
| 187 | cmp := semver.Compare(version, target) |
| 188 | switch op { |
| 189 | case ">=", "": |
| 190 | if cmp < 0 { |
| 191 | return false |
| 192 | } |
| 193 | case ">": |
| 194 | if cmp <= 0 { |
| 195 | return false |
| 196 | } |
| 197 | case "<=": |
| 198 | if cmp > 0 { |
| 199 | return false |
| 200 | } |
| 201 | case "<": |
| 202 | if cmp >= 0 { |
| 203 | return false |
| 204 | } |
| 205 | case "=", "==": |
| 206 | if cmp != 0 { |
| 207 | return false |
| 208 | } |
| 209 | default: |
| 210 | return false |
| 211 | } |
| 212 | } |
| 213 | return true |
| 214 | } |
| 215 | |
| 216 | func splitRangeClause(part string) (op, ver string, ok bool) { |
| 217 | for _, candidate := range []string{">=", "<=", "==", ">", "<", "="} { |
| 218 | if strings.HasPrefix(part, candidate) { |
| 219 | return candidate, strings.TrimSpace(part[len(candidate):]), true |
| 220 | } |
| 221 | } |
| 222 | // Bare version means exact match. |
| 223 | if semver.IsValid(normalizeVersion(part)) { |
| 224 | return "=", part, true |
| 225 | } |
| 226 | return "", "", false |
| 227 | } |
| 228 |