Skip to main content

ambient_id/
gitlab.rs

1//! GitLab CI OIDC token detection.
2
3use crate::{DetectionState, DetectionStrategy};
4
5/// Possible errors during GitLab CI OIDC token detection.
6#[derive(Debug, thiserror::Error)]
7pub enum Error {
8    /// The expected environment variable for the ID token was not found.
9    #[error("ID token variable not found: {0}")]
10    Missing(String),
11}
12
13pub(crate) struct GitLabCI;
14
15impl GitLabCI {
16    /// Normalizes an audience string into the format required
17    /// for GitLab CI ID token environment variables.
18    ///
19    /// Specifically, this uppercases all alphanumeric characters
20    /// and replaces all non-alphanumeric characters with underscores.
21    ///
22    /// For example, "sigstore" becomes "SIGSTORE",
23    /// and "http://test.audience" becomes "HTTP___TEST_AUDIENCE".
24    fn normalized_audience(audience: &str) -> String {
25        audience
26            .chars()
27            .map(|c| {
28                if c.is_ascii_alphanumeric() {
29                    c.to_ascii_uppercase()
30                } else {
31                    '_'
32                }
33            })
34            .collect()
35    }
36}
37
38impl DetectionStrategy for GitLabCI {
39    type Error = Error;
40
41    fn new(_state: &DetectionState) -> Option<Self> {
42        std::env::var("GITLAB_CI")
43            .ok()
44            // Per GitLab docs, this is exactly "true" when
45            // running in GitLab CI.
46            .filter(|v| v == "true")
47            .map(|_| GitLabCI)
48    }
49
50    /// On GitLab CI, the OIDC token URL is provided via an environment variable.
51    /// Specifically, we look for `<AUD>_ID_TOKEN` where `<AUD>` is the
52    /// audience, uppercased and with non-ASCII-alphanumeric characters replaced by `_`.
53    ///
54    /// As an example, audience "sigstore" would require variable SIGSTORE_ID_TOKEN,
55    /// and audience "http://test.audience" would require variable
56    /// HTTP___TEST_AUDIENCE_ID_TOKEN.
57    async fn detect(&self, audience: &str) -> Result<crate::IdToken, Self::Error> {
58        let normalized_audience = Self::normalized_audience(audience);
59
60        let var_name = format!("{normalized_audience}_ID_TOKEN");
61        let token = std::env::var(&var_name).map_err(|_| Error::Missing(var_name))?;
62
63        Ok(crate::IdToken(token.into()))
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use crate::{DetectionStrategy as _, gitlab::Error, tests::EnvScope};
70
71    use super::GitLabCI;
72
73    #[test]
74    fn test_normalized_audience() {
75        let cases = [
76            ("sigstore", "SIGSTORE"),
77            ("http://test.audience", "HTTP___TEST_AUDIENCE"),
78            ("my-audience_123", "MY_AUDIENCE_123"),
79            ("Audience With Spaces!", "AUDIENCE_WITH_SPACES_"),
80            // TODO(ww): This mirrors what `id` does, but maybe we should
81            // reject audiences with non-ASCII characters? Or reject those
82            // that normalize to only underscores?
83            ("😭", "_"),
84            ("😭😭😭", "___"),
85        ];
86
87        for (input, expected) in cases {
88            assert_eq!(GitLabCI::normalized_audience(input), expected);
89        }
90    }
91
92    #[tokio::test]
93    async fn test_detected() {
94        let mut scope = EnvScope::new();
95        scope.setenv("GITLAB_CI", "true");
96
97        assert!(GitLabCI::new(&Default::default()).is_some())
98    }
99
100    #[tokio::test]
101    async fn test_not_detected() {
102        let mut scope = EnvScope::new();
103        scope.unsetenv("GITLAB_CI");
104
105        assert!(GitLabCI::new(&Default::default()).is_none());
106    }
107
108    #[tokio::test]
109    async fn test_not_detected_wrong_value() {
110        for value in &["", "false", "TRUE", "1", "yes"] {
111            let mut scope = EnvScope::new();
112            scope.setenv("GITLAB_CI", value);
113
114            assert!(GitLabCI::new(&Default::default()).is_none());
115        }
116    }
117
118    #[tokio::test]
119    async fn test_invalid_missing() {
120        let mut scope = EnvScope::new();
121        scope.setenv("GITLAB_CI", "true");
122        scope.setenv("WRONG_ID_TOKEN", "sometoken");
123
124        let detector = GitLabCI::new(&Default::default()).expect("should detect GitLab CI");
125        assert!(matches!(
126            detector.detect("bupkis").await,
127            Err(Error::Missing(_))
128        ));
129    }
130
131    #[tokio::test]
132    async fn test_ok() {
133        let mut scope = EnvScope::new();
134        scope.setenv("GITLAB_CI", "true");
135        scope.setenv("BUPKIS_ID_TOKEN", "sometoken");
136
137        let detector = GitLabCI::new(&Default::default()).expect("should detect GitLab CI");
138        let token = detector.detect("bupkis").await.expect("should fetch token");
139        assert_eq!(token.reveal(), "sometoken");
140    }
141}