gha_github_service_proof/
call.rs1use crate::catalog;
2use crate::model::{
3 CallReport, Check, Compatibility, PermissionSet, ReceiptSummary, RequiredPermission,
4};
5use crate::permissions;
6
7#[derive(Debug, Clone)]
8pub struct ClassifyOptions {
9 pub method: String,
10 pub path: String,
11 pub url: Option<String>,
12 pub origin: Option<String>,
13 pub permissions: Option<PermissionSet>,
14}
15
16pub fn classify(options: ClassifyOptions) -> CallReport {
17 let mut checks = Vec::new();
18 let method = options.method.trim().to_uppercase();
19 let path = options.path.clone();
20
21 let location = options
22 .origin
23 .clone()
24 .unwrap_or_else(|| format!("{method} {path}"));
25
26 if catalog::is_graphql_path(&path) {
27 checks.push(
28 Check::fail(
29 "call.graphql_classification_not_implemented",
30 "graphql.classification_not_implemented: /graphql operations are not classified by v1.0; ci-forge should treat the call as unsupported until a future release adds operation parsing",
31 )
32 .at(location.clone()),
33 );
34 return CallReport {
35 method,
36 path,
37 url: options.url,
38 classification: Compatibility::Unsupported,
39 catalog_match: None,
40 unsupported_reason: Some("graphql.classification_not_implemented".to_owned()),
41 permissions: options.permissions,
42 satisfied: false,
43 missing_permissions: Vec::new(),
44 checks,
45 origin: options.origin,
46 };
47 }
48
49 let Some(catalog_match) = catalog::lookup(&method, &path) else {
50 checks.push(
51 Check::warn(
52 "call.endpoint_not_in_catalog",
53 format!(
54 "rest.endpoint_not_in_catalog: {method} {path} is not covered by the v1.0 CI-relevant catalog; ci-forge should classify as unsupported"
55 ),
56 )
57 .at(location.clone()),
58 );
59 return CallReport {
60 method,
61 path,
62 url: options.url,
63 classification: Compatibility::Unsupported,
64 catalog_match: None,
65 unsupported_reason: Some("rest.endpoint_not_in_catalog".to_owned()),
66 permissions: options.permissions,
67 satisfied: false,
68 missing_permissions: Vec::new(),
69 checks,
70 origin: options.origin,
71 };
72 };
73
74 let permissions_set = options.permissions.clone();
75 let (satisfied, missing) = evaluate_permissions(
76 permissions_set.as_ref(),
77 &catalog_match.required_permissions,
78 );
79
80 if catalog_match.required_permissions.is_empty() {
81 checks.push(
82 Check::pass(
83 format!("call.{}", catalog_match.endpoint_id),
84 format!(
85 "{method} {path} maps to {} ({}); no specific permission scopes are required",
86 catalog_match.endpoint_id,
87 catalog_match.classification.as_str()
88 ),
89 )
90 .at(location.clone()),
91 );
92 } else if satisfied {
93 checks.push(
94 Check::pass(
95 format!("call.{}", catalog_match.endpoint_id),
96 format!(
97 "{method} {path} maps to {} ({}); granted permissions satisfy required scopes",
98 catalog_match.endpoint_id,
99 catalog_match.classification.as_str()
100 ),
101 )
102 .at(location.clone()),
103 );
104 } else if permissions_set.is_none() {
105 checks.push(
106 Check::warn(
107 format!("call.{}.permissions_unknown", catalog_match.endpoint_id),
108 format!(
109 "{method} {path} maps to {} ({}); no permissions provided so requirement {} cannot be evaluated",
110 catalog_match.endpoint_id,
111 catalog_match.classification.as_str(),
112 format_required(&catalog_match.required_permissions),
113 ),
114 )
115 .at(location.clone()),
116 );
117 } else {
118 checks.push(
119 Check::fail(
120 format!(
121 "call.{}.permissions_insufficient",
122 catalog_match.endpoint_id
123 ),
124 format!(
125 "{method} {path} maps to {} ({}); missing {}",
126 catalog_match.endpoint_id,
127 catalog_match.classification.as_str(),
128 format_required(&missing),
129 ),
130 )
131 .at(location.clone()),
132 );
133 }
134
135 let classification = catalog_match.classification;
136
137 CallReport {
138 method,
139 path,
140 url: options.url,
141 classification,
142 catalog_match: Some(catalog_match),
143 unsupported_reason: None,
144 permissions: permissions_set,
145 satisfied,
146 missing_permissions: missing,
147 checks,
148 origin: options.origin,
149 }
150}
151
152pub fn evaluate_permissions(
153 set: Option<&PermissionSet>,
154 required: &[RequiredPermission],
155) -> (bool, Vec<RequiredPermission>) {
156 let Some(set) = set else {
157 if required.is_empty() {
158 return (true, Vec::new());
159 }
160 return (false, required.to_vec());
161 };
162 let missing = permissions::missing(set, required);
163 (missing.is_empty(), missing)
164}
165
166pub fn summary(report: &CallReport) -> ReceiptSummary {
167 ReceiptSummary::from_checks(&report.checks)
168}
169
170pub fn format_required(required: &[RequiredPermission]) -> String {
171 if required.is_empty() {
172 return "(none)".to_owned();
173 }
174 required
175 .iter()
176 .map(|req| format!("{}:{}", req.key.as_str(), req.level.as_str()))
177 .collect::<Vec<_>>()
178 .join(", ")
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::model::{CheckStatus, PermissionKey, PermissionLevel};
185
186 fn perms_with(key: PermissionKey, level: PermissionLevel) -> PermissionSet {
187 let mut set = PermissionSet::default();
188 set.entries.insert(key.as_str().to_owned(), level);
189 set
190 }
191
192 #[test]
193 fn classify_in_catalog_with_sufficient_permissions() {
194 let report = classify(ClassifyOptions {
195 method: "POST".to_owned(),
196 path: "/repos/wildmason/mortar/releases".to_owned(),
197 url: None,
198 origin: Some("workflow ci.yml job release step 0".to_owned()),
199 permissions: Some(perms_with(PermissionKey::Contents, PermissionLevel::Write)),
200 });
201 assert!(matches!(report.classification, Compatibility::Simulated));
202 assert!(report.satisfied);
203 assert!(report.missing_permissions.is_empty());
204 assert!(report.catalog_match.is_some());
205 assert!(
206 report
207 .checks
208 .iter()
209 .any(|c| matches!(c.status, CheckStatus::Pass))
210 );
211 }
212
213 #[test]
214 fn classify_in_catalog_with_missing_permissions_fails() {
215 let report = classify(ClassifyOptions {
216 method: "POST".to_owned(),
217 path: "/repos/wildmason/mortar/releases".to_owned(),
218 url: None,
219 origin: None,
220 permissions: Some(perms_with(PermissionKey::Contents, PermissionLevel::Read)),
221 });
222 assert!(matches!(report.classification, Compatibility::Simulated));
223 assert!(!report.satisfied);
224 assert_eq!(report.missing_permissions.len(), 1);
225 assert!(
226 report
227 .checks
228 .iter()
229 .any(|c| matches!(c.status, CheckStatus::Fail))
230 );
231 }
232
233 #[test]
234 fn classify_off_catalog_returns_unsupported() {
235 let report = classify(ClassifyOptions {
236 method: "POST".to_owned(),
237 path: "/repos/wildmason/mortar/branches/main/protection".to_owned(),
238 url: None,
239 origin: None,
240 permissions: Some(perms_with(PermissionKey::Contents, PermissionLevel::Write)),
241 });
242 assert!(matches!(report.classification, Compatibility::Unsupported));
243 assert!(report.catalog_match.is_none());
244 assert_eq!(
245 report.unsupported_reason.as_deref(),
246 Some("rest.endpoint_not_in_catalog"),
247 );
248 }
249
250 #[test]
251 fn classify_graphql_returns_unsupported_with_specific_reason() {
252 let report = classify(ClassifyOptions {
253 method: "POST".to_owned(),
254 path: "/graphql".to_owned(),
255 url: None,
256 origin: None,
257 permissions: None,
258 });
259 assert!(matches!(report.classification, Compatibility::Unsupported));
260 assert!(report.catalog_match.is_none());
261 assert_eq!(
262 report.unsupported_reason.as_deref(),
263 Some("graphql.classification_not_implemented"),
264 );
265 }
266
267 #[test]
268 fn classify_no_required_permissions_passes_without_permissions() {
269 let report = classify(ClassifyOptions {
270 method: "GET".to_owned(),
271 path: "/rate_limit".to_owned(),
272 url: None,
273 origin: None,
274 permissions: None,
275 });
276 assert!(matches!(report.classification, Compatibility::Exact));
277 assert!(report.satisfied);
278 assert!(
279 report
280 .checks
281 .iter()
282 .any(|c| matches!(c.status, CheckStatus::Pass))
283 );
284 }
285
286 #[test]
287 fn classify_in_catalog_without_permissions_warns() {
288 let report = classify(ClassifyOptions {
289 method: "POST".to_owned(),
290 path: "/repos/wildmason/mortar/releases".to_owned(),
291 url: None,
292 origin: None,
293 permissions: None,
294 });
295 assert!(matches!(report.classification, Compatibility::Simulated));
296 assert!(!report.satisfied);
297 assert!(
298 report
299 .checks
300 .iter()
301 .any(|c| matches!(c.status, CheckStatus::Warn))
302 );
303 }
304}