1use std::env;
29use std::fmt;
30use std::fs::create_dir_all;
31use std::io::Error as IoError;
32use std::path::{Path, PathBuf};
33use std::process::{Command, Output};
34
35use sha2::{Digest, Sha256};
36use thiserror::Error;
37
38use crate::config::RuleSource;
39
40const GIT_TOKEN_ENV: &str = "SLOPGUARD_GIT_TOKEN";
43
44const GIT_TOKEN_HOST_ENV: &str = "SLOPGUARD_GIT_TOKEN_HOST";
47
48const ALLOW_PROTOCOL_ENV: &str = "SLOPGUARD_GIT_ALLOW_PROTOCOL";
52
53pub const DEFAULT_ALLOWED_PROTOCOLS: &str = "https:ssh";
55
56const HTTPS_PREFIX: &str = "https://";
57
58const REDACTED: &str = "<redacted>";
60
61#[derive(Clone, PartialEq, Eq)]
63pub struct GitToken {
64 pub token: String,
66 pub host: String,
69}
70
71impl fmt::Debug for GitToken {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.debug_struct("GitToken")
75 .field("token", &REDACTED)
76 .field("host", &self.host)
77 .finish()
78 }
79}
80
81pub fn git_token_from_env(config_host: Option<&str>) -> Option<GitToken> {
87 let token = non_empty_env(GIT_TOKEN_ENV)?;
88 let config_host = config_host.filter(|h| !h.is_empty()).map(str::to_string);
89 let host = non_empty_env(GIT_TOKEN_HOST_ENV).or(config_host)?;
90 Some(GitToken { token, host })
91}
92
93pub fn default_allowed_protocols() -> String {
96 non_empty_env(ALLOW_PROTOCOL_ENV).unwrap_or_else(|| DEFAULT_ALLOWED_PROTOCOLS.to_string())
97}
98
99fn non_empty_env(name: &str) -> Option<String> {
100 env::var(name).ok().filter(|value| !value.is_empty())
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Default)]
105pub enum RuleOrigin {
106 #[default]
108 Builtin,
109 Git { url: String },
111 Local { path: PathBuf },
113}
114
115impl RuleOrigin {
116 pub fn label(&self) -> String {
119 match self {
120 RuleOrigin::Builtin => "builtin".to_string(),
121 RuleOrigin::Git { url } => url.clone(),
122 RuleOrigin::Local { path } => path.display().to_string(),
123 }
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct ResolvedSource {
130 pub dir: PathBuf,
132 pub origin: RuleOrigin,
134}
135
136#[derive(Debug, Clone)]
138pub struct SourceOptions {
139 pub cache_root: PathBuf,
141 pub offline: bool,
143 pub git_token: Option<GitToken>,
145 pub allowed_protocols: String,
147}
148
149impl Default for SourceOptions {
150 fn default() -> Self {
151 Self {
152 cache_root: default_cache_root(),
153 offline: false,
154 git_token: git_token_from_env(None),
155 allowed_protocols: default_allowed_protocols(),
156 }
157 }
158}
159
160const CACHE_ENV: &str = "SLOPGUARD_SOURCES_CACHE";
163
164pub fn default_cache_root() -> PathBuf {
167 if let Ok(dir) = env::var(CACHE_ENV) {
168 if !dir.is_empty() {
169 return PathBuf::from(dir);
170 }
171 }
172 dirs::cache_dir()
173 .unwrap_or_else(|| PathBuf::from(".cache"))
174 .join("slopguard")
175 .join("sources")
176}
177
178#[derive(Debug, Error)]
179pub enum SourceError {
180 #[error("failed to run git (is it installed and on PATH?): {source}")]
181 Spawn {
182 #[source]
183 source: IoError,
184 },
185
186 #[error("git {args} failed for source '{url}': {stderr}")]
187 Git {
188 url: String,
189 args: String,
190 stderr: String,
191 },
192
193 #[error("offline: no cached clone for git source '{url}' (expected at '{path}')")]
194 OfflineNoCache { url: String, path: String },
195
196 #[error("failed to prepare cache directory '{path}': {source}")]
197 Cache {
198 path: String,
199 #[source]
200 source: IoError,
201 },
202
203 #[error("local rule source '{path}' does not exist or is not a directory")]
204 LocalMissing { path: String },
205
206 #[error("git source {what} '{value}' must not start with '-'")]
207 InvalidArgument { what: &'static str, value: String },
208}
209
210pub fn is_pinned_sha(git_ref: &str) -> bool {
213 git_ref.len() == 40 && git_ref.bytes().all(|b| b.is_ascii_hexdigit())
214}
215
216pub fn unpinned_source_warnings(sources: &[RuleSource]) -> Vec<String> {
219 sources
220 .iter()
221 .filter_map(|source| {
222 let url = source.git.as_deref()?;
223 let reason = match source.git_ref.as_deref() {
224 Some(git_ref) if is_pinned_sha(git_ref) => return None,
225 Some(git_ref) => format!("ref '{git_ref}' is not a 40-character commit sha"),
226 None => "no ref set, the default branch is used".to_string(),
227 };
228 Some(format!(
229 "unpinned rule source '{}': {reason}; pin it to a commit sha so its rules cannot change unnoticed",
230 without_userinfo(url)
231 ))
232 })
233 .collect()
234}
235
236fn url_authority(url: &str) -> Option<&str> {
238 let (_, rest) = url.split_once("://")?;
239 Some(rest.split('/').next().unwrap_or(rest))
240}
241
242fn url_host(url: &str) -> Option<&str> {
245 let authority = url_authority(url)?;
246 let host_port = authority.rsplit('@').next().unwrap_or(authority);
247 Some(host_port.split(':').next().unwrap_or(host_port))
248}
249
250fn without_userinfo(url: &str) -> String {
252 let Some((scheme, rest)) = url.split_once("://") else {
253 return url.to_string();
254 };
255 let authority = rest.split('/').next().unwrap_or(rest);
256 match authority.rsplit_once('@') {
257 Some((_, host)) => {
258 let path = &rest[authority.len()..];
259 format!("{scheme}://{REDACTED}@{host}{path}")
260 }
261 None => url.to_string(),
262 }
263}
264
265fn cache_dir_for(root: &Path, url: &str, git_ref: Option<&str>) -> PathBuf {
268 let mut hasher = Sha256::new();
269 hasher.update(url.as_bytes());
270 hasher.update([0u8]);
271 if let Some(git_ref) = git_ref {
272 hasher.update(git_ref.as_bytes());
273 }
274 let digest = hasher.finalize();
275 let hex: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
276 root.join(hex)
277}
278
279fn fetch_url(url: &str, token: Option<&GitToken>) -> String {
283 let (Some(token), Some(rest)) = (token, url.strip_prefix(HTTPS_PREFIX)) else {
284 return url.to_string();
285 };
286 let has_userinfo = url_authority(url).is_some_and(|authority| authority.contains('@'));
287 let trusted = url_host(url).is_some_and(|host| host.eq_ignore_ascii_case(&token.host));
288 if has_userinfo || !trusted {
289 return url.to_string();
290 }
291 format!("{HTTPS_PREFIX}oauth2:{}@{rest}", token.token)
292}
293
294fn validate_git_arg(what: &'static str, value: &str) -> Result<(), SourceError> {
296 if value.starts_with('-') {
297 return Err(SourceError::InvalidArgument {
298 what,
299 value: value.to_string(),
300 });
301 }
302 Ok(())
303}
304
305fn run_git(dir: &Path, args: &[&str], allowed_protocols: &str) -> Result<Output, SourceError> {
307 Command::new("git")
308 .current_dir(dir)
309 .args(args)
310 .env("GIT_ALLOW_PROTOCOL", allowed_protocols)
311 .output()
312 .map_err(|source| SourceError::Spawn { source })
313}
314
315fn stderr_of(output: &Output) -> String {
316 String::from_utf8_lossy(&output.stderr).trim().to_string()
317}
318
319fn fetch_and_checkout(
323 dir: &Path,
324 url: &str,
325 git_ref: Option<&str>,
326 opts: &SourceOptions,
327) -> Result<(), SourceError> {
328 let refspec = git_ref.unwrap_or("HEAD");
329 validate_git_arg("url", url)?;
330 validate_git_arg("ref", refspec)?;
331 let token = opts.git_token.as_ref();
332 let tokened = fetch_url(url, token);
333 let fetch = run_git(
334 dir,
335 &["fetch", "--depth", "1", "--", &tokened, refspec],
336 &opts.allowed_protocols,
337 )?;
338 if !fetch.status.success() {
339 let mut stderr = stderr_of(&fetch);
340 if let Some(token) = token {
341 stderr = stderr.replace(&token.token, REDACTED);
342 }
343 return Err(SourceError::Git {
344 url: url.to_string(),
345 args: format!("fetch {refspec}"),
346 stderr,
347 });
348 }
349 let checkout = run_git(
350 dir,
351 &[
352 "-c",
353 "advice.detachedHead=false",
354 "checkout",
355 "--force",
356 "FETCH_HEAD",
357 ],
358 &opts.allowed_protocols,
359 )?;
360 if !checkout.status.success() {
361 return Err(SourceError::Git {
362 url: url.to_string(),
363 args: "checkout FETCH_HEAD".to_string(),
364 stderr: stderr_of(&checkout),
365 });
366 }
367 Ok(())
368}
369
370fn is_checked_out(dir: &Path, sha: &str, allowed_protocols: &str) -> bool {
372 let Ok(output) = run_git(dir, &["rev-parse", "HEAD"], allowed_protocols) else {
373 return false;
374 };
375 let head = String::from_utf8_lossy(&output.stdout);
376 output.status.success() && head.trim().eq_ignore_ascii_case(sha)
377}
378
379fn ensure_git_source(
388 url: &str,
389 git_ref: Option<&str>,
390 opts: &SourceOptions,
391) -> Result<PathBuf, SourceError> {
392 validate_git_arg("url", url)?;
393 if let Some(git_ref) = git_ref {
394 validate_git_arg("ref", git_ref)?;
395 }
396 let dir = cache_dir_for(&opts.cache_root, url, git_ref);
397 let is_repo = dir.join(".git").is_dir();
398
399 if !is_repo {
400 if opts.offline {
401 return Err(SourceError::OfflineNoCache {
402 url: url.to_string(),
403 path: dir.display().to_string(),
404 });
405 }
406 create_dir_all(&dir).map_err(|source| SourceError::Cache {
407 path: dir.display().to_string(),
408 source,
409 })?;
410 let init = run_git(&dir, &["init", "--quiet"], &opts.allowed_protocols)?;
411 if !init.status.success() {
412 return Err(SourceError::Git {
413 url: url.to_string(),
414 args: "init".to_string(),
415 stderr: stderr_of(&init),
416 });
417 }
418 fetch_and_checkout(&dir, url, git_ref, opts)?;
419 } else if !opts.offline {
420 let pinned_present = git_ref.is_some_and(|sha| {
421 is_pinned_sha(sha) && is_checked_out(&dir, sha, &opts.allowed_protocols)
422 });
423 if !pinned_present {
424 fetch_and_checkout(&dir, url, git_ref, opts)?;
425 }
426 }
427
428 Ok(dir)
429}
430
431pub fn resolve_sources(
437 sources: &[RuleSource],
438 opts: &SourceOptions,
439) -> Result<Vec<ResolvedSource>, SourceError> {
440 let mut resolved = Vec::with_capacity(sources.len());
441 for source in sources {
442 if let Some(url) = &source.git {
443 let repo = ensure_git_source(url, source.git_ref.as_deref(), opts)?;
444 let dir = match &source.path {
445 Some(sub) => repo.join(sub),
446 None => repo,
447 };
448 resolved.push(ResolvedSource {
449 dir,
450 origin: RuleOrigin::Git { url: url.clone() },
451 });
452 } else if let Some(path) = &source.path {
453 if !path.is_dir() {
454 return Err(SourceError::LocalMissing {
455 path: path.display().to_string(),
456 });
457 }
458 resolved.push(ResolvedSource {
459 dir: path.clone(),
460 origin: RuleOrigin::Local { path: path.clone() },
461 });
462 }
463 }
464 Ok(resolved)
465}
466
467#[cfg(test)]
468mod tests;