Skip to main content

gha_github_service_proof/
call.rs

1use 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}