Repository navigation
Expand file tree
/
Copy pathruntime_inputs.go
More file actions
271 lines (243 loc) · 9.31 KB
/
Copy pathruntime_inputs.go
File metadata and controls
271 lines (243 loc) · 9.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
//
// Copyright 2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package policies
import (
"maps"
"slices"
"strings"
"sync"
)
// RuntimeInputs holds policy input values supplied at runtime (e.g. via
// --policy-input-from-file or --policy-input).
// Inputs are either global (applied to every policy attachment that declares
// them) or scoped to a specific policy (applied only to the attachment whose
// metadata name or ref matches the scope key). Independently, each input is
// merged onto the contract value either additively (append) or by replacing it
// (override); the two modes live in separate maps so the same input name can be
// appended by one flag and never collides with an override of another.
type RuntimeInputs struct {
// Global holds append-mode inputs, keyed by input name.
Global map[string]string
// Scoped holds append-mode inputs, keyed by policy scope (a policy name or
// ref) then input name.
Scoped map[string]map[string]string
// GlobalOverride holds replace-mode inputs, keyed by input name.
GlobalOverride map[string]string
// ScopedOverride holds replace-mode inputs, keyed by policy scope then input
// name.
ScopedOverride map[string]map[string]string
}
// empty reports whether ri carries no inputs at all. Nil-safe.
func (ri *RuntimeInputs) empty() bool {
return ri == nil || (len(ri.Global) == 0 && len(ri.Scoped) == 0 &&
len(ri.GlobalOverride) == 0 && len(ri.ScopedOverride) == 0)
}
// forPolicy returns the runtime inputs that apply to a policy attachment
// identified by its metadata name and raw ref, together with the scope keys
// that matched. Append-mode inputs (appendInputs) merge the global inputs with
// any scoped entries whose key matches the attachment (additively when they
// share an input name). Replace-mode inputs (replaceInputs) merge the global
// overrides with any matching scoped overrides, the scoped ones winning. The
// caller layers appendInputs onto the contract additively and then applies
// replaceInputs on top, so a replace always wins over an append for the same
// key. Returns (nil, nil, nil) when nothing applies. Nil-safe.
func (ri *RuntimeInputs) forPolicy(name, ref string) (appendInputs, replaceInputs map[string]string, matched []string) {
if ri.empty() {
return nil, nil, nil
}
seen := make(map[string]struct{})
markMatch := func(scope string) {
if _, ok := seen[scope]; !ok {
seen[scope] = struct{}{}
matched = append(matched, scope)
}
}
// Apply matching scopes least-specific first so that, when several scopes
// target the same attachment and set the same input, the most specific one
// is applied last and wins — deterministically, regardless of Go's map
// iteration order.
appendInputs = ri.Global
for _, scope := range matchingScopes(ri.Scoped, name, ref) {
markMatch(scope)
appendInputs = MergeRuntimeInputs(appendInputs, ri.Scoped[scope])
}
replaceInputs = ri.GlobalOverride
for _, scope := range matchingScopes(ri.ScopedOverride, name, ref) {
markMatch(scope)
replaceInputs = OverrideRuntimeInputs(replaceInputs, ri.ScopedOverride[scope])
}
return appendInputs, replaceInputs, matched
}
// matchingScopes returns the keys of scoped that target the attachment
// identified by (name, ref), ordered so more specific scopes come last. Callers
// apply them in that order, so a later (more specific) scope wins when several
// set the same input. Ties break on the scope string, so the order — and thus
// the merged result — is deterministic regardless of Go's randomized map
// iteration.
func matchingScopes[V any](scoped map[string]V, name, ref string) []string {
var out []string
for scope := range scoped {
if policyScopeMatches(scope, name, ref) {
out = append(out, scope)
}
}
slices.SortFunc(out, func(a, b string) int {
if d := scopeSpecificity(a) - scopeSpecificity(b); d != 0 {
return d
}
return strings.Compare(a, b)
})
return out
}
// scopeSpecificity scores how narrowly a scope key targets a policy: a scope
// that pins a digest is the most specific, and a scope carrying anything beyond
// the bare policy name — a scheme (chainloop://), an org path (org/name) or a
// provider prefix (provider:name) — is more specific than a bare name.
func scopeSpecificity(scope string) int {
_, digest := splitPolicyRef(scope)
// Drop the "@sha256:<digest>" suffix so its own ':' does not count as
// provider/scheme qualification below.
head := scope
if digest != "" {
if i := strings.Index(scope, "@"); i >= 0 {
head = scope[:i]
}
}
score := 0
if digest != "" {
score += 2
}
// A scheme (chainloop://), org path (org/name) or provider prefix
// (provider:name) all introduce a ':' or '/' beyond the bare name.
if strings.ContainsAny(head, ":/") {
score++
}
return score
}
// policyScopeMatches reports whether a runtime-input scope key targets the
// policy attachment identified by its metadata name and raw ref. A scope
// matches when it equals the name or the ref exactly, or when its bare name
// (scheme, org and @sha256: digest stripped) matches; if the scope pins a
// digest, the ref must carry the same digest, otherwise any version matches.
func policyScopeMatches(scope, name, ref string) bool {
if scope == "" {
return false
}
if scope == name || scope == ref {
return true
}
scopeName, scopeDigest := splitPolicyRef(scope)
if scopeName == "" {
return false
}
refName, refDigest := splitPolicyRef(ref)
if scopeName != refName && scopeName != name {
return false
}
if scopeDigest != "" {
return scopeDigest == refDigest
}
return true
}
// splitPolicyRef normalizes a policy reference to its bare name and digest,
// stripping any scheme, org scope and @sha256: version using the same parsers
// the loaders use.
func splitPolicyRef(ref string) (name, digest string) {
return ExtractDigest(ProviderParts(ref).Name)
}
// MergeRuntimeInputs returns the contract arguments with the runtime inputs
// merged in additively: when both define the same key, the runtime value is
// appended after the contract value (newline-separated) so file-sourced
// exemptions add to, rather than replace, contract-declared ones. The input
// maps are not mutated. Exported so callers assembling runtime inputs (e.g. the
// CLI's --policy-input-from-file handling) reuse the same multi-value encoding.
func MergeRuntimeInputs(with, runtimeInputs map[string]string) map[string]string {
if len(runtimeInputs) == 0 {
return with
}
merged := make(map[string]string, len(with)+len(runtimeInputs))
maps.Copy(merged, with)
for k, v := range runtimeInputs {
if existing := merged[k]; existing != "" {
merged[k] = existing + "\n" + v
} else {
merged[k] = v
}
}
return merged
}
// OverrideRuntimeInputs returns the given arguments with the override inputs
// applied by replacement: each override key's value replaces whatever the
// arguments held for that key (a contract value or an appended runtime value),
// rather than being newline-appended. This is what makes a scalar input
// overridable at run time: the value no longer collapses into a multi-value
// list through append-time newline joining. The value is still normalized like
// any other input downstream (getInputArguments splits it on newlines and
// commas), so a single comma-free value such as "10" stays a scalar while a
// value that itself contains commas expands into a list; a literal comma must
// be escaped as "\,". The input maps are not mutated.
func OverrideRuntimeInputs(with, overrides map[string]string) map[string]string {
if len(overrides) == 0 {
return with
}
merged := make(map[string]string, len(with)+len(overrides))
maps.Copy(merged, with)
maps.Copy(merged, overrides)
return merged
}
// scopeTracker records, concurrency-safely, which runtime-input scope keys were
// matched by at least one policy attachment during a material evaluation.
type scopeTracker struct {
mu sync.Mutex
seen map[string]struct{}
}
func newScopeTracker() *scopeTracker {
return &scopeTracker{seen: make(map[string]struct{})}
}
func (t *scopeTracker) mark(keys ...string) {
if t == nil || len(keys) == 0 {
return
}
t.mu.Lock()
defer t.mu.Unlock()
for _, k := range keys {
t.seen[k] = struct{}{}
}
}
// unmatched returns the sorted scope keys declared in ri (across both append-
// and replace-mode scoped inputs) that were never marked (i.e. matched no policy
// attachment), so the caller can warn about likely typos. Nil-safe.
func (t *scopeTracker) unmatched(ri *RuntimeInputs) []string {
if t == nil || ri == nil {
return nil
}
t.mu.Lock()
defer t.mu.Unlock()
scopes := make(map[string]struct{}, len(ri.Scoped)+len(ri.ScopedOverride))
for scope := range ri.Scoped {
scopes[scope] = struct{}{}
}
for scope := range ri.ScopedOverride {
scopes[scope] = struct{}{}
}
var out []string
for scope := range scopes {
if _, ok := t.seen[scope]; !ok {
out = append(out, scope)
}
}
slices.Sort(out)
return out
}