Skip to main content

lean_ctx/core/providers/
selected_gitlab.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Immutable source authority for an explicitly selected protected MCP session.
3//! This is acquisition authorization, not authorization of previously stored context.
4use super::config::GitLabConfig;
5#[cfg(any(unix, windows))]
6use std::sync::Arc;
7use std::sync::OnceLock;
8
9#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct Selection {
12    pub host: String,
13    pub project: u64,
14    pub namespace: String,
15}
16
17impl Selection {
18    pub fn validate(&self) -> Result<(), String> {
19        let url = reqwest::Url::parse(&format!("https://{}", self.host))
20            .map_err(|_| "invalid selected GitLab host")?;
21        if self.host.is_empty()
22            || self.host.len() > 253
23            || self.host.chars().any(char::is_control)
24            || url.scheme() != "https"
25            || url.host_str().is_none()
26            || !url.username().is_empty()
27            || url.password().is_some()
28            || url.path() != "/"
29            || url.query().is_some()
30            || url.fragment().is_some()
31            || url.origin().ascii_serialization() != format!("https://{}", self.host)
32        {
33            return Err("selected GitLab host must be a canonical HTTPS authority".into());
34        }
35        if self.project == 0
36            || self.namespace.len() > 1024
37            || !self.namespace.contains('/')
38            || self.namespace.split('/').any(|segment| {
39                segment.is_empty()
40                    || matches!(segment, "." | "..")
41                    || !segment
42                        .bytes()
43                        .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
44            })
45        {
46            return Err("selected GitLab needs a positive project ID and a valid namespace".into());
47        }
48        Ok(())
49    }
50
51    pub fn check_project(&self, project: Option<&str>) -> Result<(), String> {
52        if project.is_some_and(|p| p != self.project.to_string() && p != self.namespace) {
53            return Err("request does not match the selected GitLab project".into());
54        }
55        Ok(())
56    }
57
58    fn check_config(&self, selected: &GitLabConfig, config: &GitLabConfig) -> Result<(), String> {
59        if config.host != self.host
60            || config.project_path.as_deref() != Some(self.project.to_string().as_str())
61            || config.token != selected.token
62        {
63            return Err("GitLab configuration cannot replace the protected source".into());
64        }
65        Ok(())
66    }
67
68    fn check_identity(&self, body: &str) -> Result<(), String> {
69        let value: serde_json::Value =
70            serde_json::from_str(body).map_err(|_| "invalid GitLab project identity response")?;
71        if value["id"].as_u64() != Some(self.project)
72            || value["path_with_namespace"].as_str() != Some(self.namespace.as_str())
73        {
74            return Err("GitLab project identity no longer matches the selected source".into());
75        }
76        Ok(())
77    }
78}
79
80struct Selected {
81    source: Selection,
82    config: GitLabConfig,
83}
84
85static SELECTED: OnceLock<Selected> = OnceLock::new();
86
87#[cfg(any(unix, windows))]
88pub fn install(source: Selection, token: String) -> Result<(), String> {
89    source.validate()?;
90    if token.is_empty()
91        || token.len() > 16 * 1024
92        || !token.bytes().all(|b| (33..=126).contains(&b))
93    {
94        return Err("invalid selected GitLab credential".into());
95    }
96    let config = GitLabConfig {
97        host: source.host.clone(),
98        token,
99        project_path: Some(source.project.to_string()),
100    };
101    SELECTED
102        .set(Selected {
103            source,
104            config: config.clone(),
105        })
106        .map_err(|_| "selected GitLab source was already installed")?;
107    super::registry::global_registry()
108        .pin(Arc::new(super::gitlab::GitLabProvider::with_config(config)))
109}
110
111pub(crate) fn config() -> Option<GitLabConfig> {
112    SELECTED.get().map(|selected| selected.config.clone())
113}
114
115pub(crate) fn reuse_binding(config: &GitLabConfig) -> Option<String> {
116    let selected = SELECTED.get()?;
117    selected
118        .source
119        .check_config(&selected.config, config)
120        .ok()?;
121    super::provenance::digest(&serde_json::json!({
122        "version": 1, "source": selected.source, "credential": config.token,
123    }))
124}
125
126pub fn check_project(project: Option<&str>) -> Result<(), String> {
127    SELECTED
128        .get()
129        .map_or(Ok(()), |selected| selected.source.check_project(project))
130}
131
132/// Re-check identity using the selected credential on every resource acquisition.
133/// The subsequent resource GET still enforces that resource's current permission.
134pub(crate) fn authorize(config: &GitLabConfig) -> Result<(), String> {
135    let Some(selected) = SELECTED.get() else {
136        return Ok(());
137    };
138    selected.source.check_config(&selected.config, config)?;
139    let body = super::hardened_http::provider_get_with_headers(
140        "gitlab",
141        &config.api_url(&format!("/projects/{}", selected.source.project)),
142        &[("PRIVATE-TOKEN", config.token.as_str())],
143    )
144    .into_body()
145    .map_err(|_| "selected GitLab identity could not be authorized")?;
146    selected.source.check_identity(&body)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    fn selection() -> Selection {
153        Selection {
154            host: "gitlab.example.test".into(),
155            project: 5,
156            namespace: "group/project".into(),
157        }
158    }
159    #[test]
160    fn selection_rejects_host_and_namespace_reinterpretation() {
161        assert!(selection().validate().is_ok());
162        for host in [
163            "https://gitlab.example.test",
164            "user@gitlab.example.test",
165            "gitlab.example.test/x",
166            "gitlab.example.test?x",
167            "gitlab.example.test#x",
168            "GitLab.example.test",
169            "gitlab.example.test/",
170        ] {
171            assert!(
172                Selection {
173                    host: host.into(),
174                    ..selection()
175                }
176                .validate()
177                .is_err(),
178                "{host}"
179            );
180        }
181        for namespace in ["", "project", "../project", "g//p", "g/p?x", "g/p\n"] {
182            assert!(
183                Selection {
184                    namespace: namespace.into(),
185                    ..selection()
186                }
187                .validate()
188                .is_err()
189            );
190        }
191        assert!(
192            Selection {
193                project: 0,
194                ..selection()
195            }
196            .validate()
197            .is_err()
198        );
199    }
200    #[test]
201    fn project_parameters_and_fresh_identity_must_match() {
202        let source = selection();
203        for project in [None, Some("5"), Some("group/project")] {
204            assert!(source.check_project(project).is_ok());
205        }
206        assert!(source.check_project(Some("other/project")).is_err());
207        assert!(
208            source
209                .check_identity(r#"{"id":5,"path_with_namespace":"group/project"}"#)
210                .is_ok()
211        );
212        for body in [
213            r#"{"id":6,"path_with_namespace":"group/project"}"#,
214            r#"{"id":5,"path_with_namespace":"moved/project"}"#,
215            "{}",
216            "not json",
217        ] {
218            assert!(source.check_identity(body).is_err());
219        }
220    }
221}