lean_ctx/core/providers/
selected_gitlab.rs1use 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
132pub(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}