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;
39use crate::sanitize::sanitize_control;
40
41const GIT_TOKEN_ENV: &str = "SLOPGUARD_GIT_TOKEN";
44
45const GIT_TOKEN_HOST_ENV: &str = "SLOPGUARD_GIT_TOKEN_HOST";
48
49const ALLOW_PROTOCOL_ENV: &str = "SLOPGUARD_GIT_ALLOW_PROTOCOL";
53
54pub const DEFAULT_ALLOWED_PROTOCOLS: &str = "https:ssh";
56
57const HTTPS_PREFIX: &str = "https://";
58
59const REDACTED: &str = "<redacted>";
61
62#[derive(Clone, PartialEq, Eq)]
64pub struct GitToken {
65 pub token: String,
67 pub host: String,
70}
71
72impl fmt::Debug for GitToken {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 f.debug_struct("GitToken")
76 .field("token", &REDACTED)
77 .field("host", &self.host)
78 .finish()
79 }
80}
81
82pub fn git_token_from_env(config_host: Option<&str>) -> Option<GitToken> {
88 let token = non_empty_env(GIT_TOKEN_ENV)?;
89 let config_host = config_host.filter(|h| !h.is_empty()).map(str::to_string);
90 let host = non_empty_env(GIT_TOKEN_HOST_ENV).or(config_host)?;
91 Some(GitToken { token, host })
92}
93
94pub fn default_allowed_protocols() -> String {
97 non_empty_env(ALLOW_PROTOCOL_ENV).unwrap_or_else(|| DEFAULT_ALLOWED_PROTOCOLS.to_string())
98}
99
100fn non_empty_env(name: &str) -> Option<String> {
101 env::var(name).ok().filter(|value| !value.is_empty())
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Default)]
106pub enum RuleOrigin {
107 #[default]
109 Builtin,
110 Git { url: String },
112 Local { path: PathBuf },
114}
115
116impl RuleOrigin {
117 pub fn label(&self) -> String {
120 match self {
121 RuleOrigin::Builtin => "builtin".to_string(),
122 RuleOrigin::Git { url } => sanitize_control(url).into_owned(),
123 RuleOrigin::Local { path } => {
124 sanitize_control(&path.display().to_string()).into_owned()
125 }
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ResolvedSource {
133 pub dir: PathBuf,
135 pub origin: RuleOrigin,
137}
138
139#[derive(Debug, Clone)]
141pub struct SourceOptions {
142 pub cache_root: PathBuf,
144 pub offline: bool,
146 pub git_token: Option<GitToken>,
148 pub allowed_protocols: String,
150}
151
152impl Default for SourceOptions {
153 fn default() -> Self {
154 Self {
155 cache_root: default_cache_root(),
156 offline: false,
157 git_token: git_token_from_env(None),
158 allowed_protocols: default_allowed_protocols(),
159 }
160 }
161}
162
163const CACHE_ENV: &str = "SLOPGUARD_SOURCES_CACHE";
166
167pub fn default_cache_root() -> PathBuf {
170 if let Ok(dir) = env::var(CACHE_ENV) {
171 if !dir.is_empty() {
172 return PathBuf::from(dir);
173 }
174 }
175 dirs::cache_dir()
176 .unwrap_or_else(|| PathBuf::from(".cache"))
177 .join("slopguard")
178 .join("sources")
179}
180
181#[derive(Debug, Error)]
182pub enum SourceError {
183 #[error("failed to run git (is it installed and on PATH?): {source}")]
184 Spawn {
185 #[source]
186 source: IoError,
187 },
188
189 #[error("git {args} failed for source '{url}': {stderr}")]
190 Git {
191 url: String,
192 args: String,
193 stderr: String,
194 },
195
196 #[error("offline: no cached clone for git source '{url}' (expected at '{path}')")]
197 OfflineNoCache { url: String, path: String },
198
199 #[error("failed to prepare cache directory '{path}': {source}")]
200 Cache {
201 path: String,
202 #[source]
203 source: IoError,
204 },
205
206 #[error("local rule source '{path}' does not exist or is not a directory")]
207 LocalMissing { path: String },
208
209 #[error("git source {what} '{value}' must not start with '-'")]
210 InvalidArgument { what: &'static str, value: String },
211}
212
213pub fn is_pinned_sha(git_ref: &str) -> bool {
216 git_ref.len() == 40 && git_ref.bytes().all(|b| b.is_ascii_hexdigit())
217}
218
219pub fn unpinned_source_warnings(sources: &[RuleSource]) -> Vec<String> {
222 sources
223 .iter()
224 .filter_map(|source| {
225 let url = source.git.as_deref()?;
226 let reason = match source.git_ref.as_deref() {
227 Some(git_ref) if is_pinned_sha(git_ref) => return None,
228 Some(git_ref) => format!(
229 "ref '{}' is not a 40-character commit sha",
230 sanitize_control(git_ref)
231 ),
232 None => "no ref set, the default branch is used".to_string(),
233 };
234 Some(format!(
235 "unpinned rule source '{}': {reason}; pin it to a commit sha so its rules cannot change unnoticed",
236 sanitize_control(&without_userinfo(url))
237 ))
238 })
239 .collect()
240}
241
242fn url_authority(url: &str) -> Option<&str> {
244 let (_, rest) = url.split_once("://")?;
245 Some(rest.split('/').next().unwrap_or(rest))
246}
247
248fn url_host(url: &str) -> Option<&str> {
251 let authority = url_authority(url)?;
252 let host_port = authority.rsplit('@').next().unwrap_or(authority);
253 Some(host_port.split(':').next().unwrap_or(host_port))
254}
255
256fn without_userinfo(url: &str) -> String {
258 let Some((scheme, rest)) = url.split_once("://") else {
259 return url.to_string();
260 };
261 let authority = rest.split('/').next().unwrap_or(rest);
262 match authority.rsplit_once('@') {
263 Some((_, host)) => {
264 let path = &rest[authority.len()..];
265 format!("{scheme}://{REDACTED}@{host}{path}")
266 }
267 None => url.to_string(),
268 }
269}
270
271fn cache_dir_for(root: &Path, url: &str, git_ref: Option<&str>) -> PathBuf {
274 let mut hasher = Sha256::new();
275 hasher.update(url.as_bytes());
276 hasher.update([0u8]);
277 if let Some(git_ref) = git_ref {
278 hasher.update(git_ref.as_bytes());
279 }
280 let digest = hasher.finalize();
281 let hex: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
282 root.join(hex)
283}
284
285fn fetch_url(url: &str, token: Option<&GitToken>) -> String {
289 let (Some(token), Some(rest)) = (token, url.strip_prefix(HTTPS_PREFIX)) else {
290 return url.to_string();
291 };
292 let has_userinfo = url_authority(url).is_some_and(|authority| authority.contains('@'));
293 let trusted = url_host(url).is_some_and(|host| host.eq_ignore_ascii_case(&token.host));
294 if has_userinfo || !trusted {
295 return url.to_string();
296 }
297 format!("{HTTPS_PREFIX}oauth2:{}@{rest}", token.token)
298}
299
300fn validate_git_arg(what: &'static str, value: &str) -> Result<(), SourceError> {
302 if value.starts_with('-') {
303 return Err(SourceError::InvalidArgument {
304 what,
305 value: sanitize_control(value).into_owned(),
306 });
307 }
308 Ok(())
309}
310
311fn run_git(dir: &Path, args: &[&str], allowed_protocols: &str) -> Result<Output, SourceError> {
313 Command::new("git")
314 .current_dir(dir)
315 .args(args)
316 .env("GIT_ALLOW_PROTOCOL", allowed_protocols)
317 .output()
318 .map_err(|source| SourceError::Spawn { source })
319}
320
321fn raw_stderr_of(output: &Output) -> String {
322 String::from_utf8_lossy(&output.stderr).trim().to_string()
323}
324
325fn stderr_of(output: &Output) -> String {
327 sanitize_control(&raw_stderr_of(output)).into_owned()
328}
329
330fn fetch_and_checkout(
334 dir: &Path,
335 url: &str,
336 git_ref: Option<&str>,
337 opts: &SourceOptions,
338) -> Result<(), SourceError> {
339 let refspec = git_ref.unwrap_or("HEAD");
340 validate_git_arg("url", url)?;
341 validate_git_arg("ref", refspec)?;
342 let token = opts.git_token.as_ref();
343 let tokened = fetch_url(url, token);
344 let fetch = run_git(
345 dir,
346 &["fetch", "--depth", "1", "--", &tokened, refspec],
347 &opts.allowed_protocols,
348 )?;
349 if !fetch.status.success() {
350 let mut stderr = raw_stderr_of(&fetch);
351 if let Some(token) = token {
352 stderr = stderr.replace(&token.token, REDACTED);
353 }
354 return Err(SourceError::Git {
355 url: sanitize_control(url).into_owned(),
356 args: format!("fetch {}", sanitize_control(refspec)),
357 stderr: sanitize_control(&stderr).into_owned(),
358 });
359 }
360 let checkout = run_git(
361 dir,
362 &[
363 "-c",
364 "advice.detachedHead=false",
365 "checkout",
366 "--force",
367 "FETCH_HEAD",
368 ],
369 &opts.allowed_protocols,
370 )?;
371 if !checkout.status.success() {
372 return Err(SourceError::Git {
373 url: sanitize_control(url).into_owned(),
374 args: "checkout FETCH_HEAD".to_string(),
375 stderr: stderr_of(&checkout),
376 });
377 }
378 Ok(())
379}
380
381fn is_checked_out(dir: &Path, sha: &str, allowed_protocols: &str) -> bool {
383 let Ok(output) = run_git(dir, &["rev-parse", "HEAD"], allowed_protocols) else {
384 return false;
385 };
386 let head = String::from_utf8_lossy(&output.stdout);
387 output.status.success() && head.trim().eq_ignore_ascii_case(sha)
388}
389
390fn ensure_git_source(
399 url: &str,
400 git_ref: Option<&str>,
401 opts: &SourceOptions,
402) -> Result<PathBuf, SourceError> {
403 validate_git_arg("url", url)?;
404 if let Some(git_ref) = git_ref {
405 validate_git_arg("ref", git_ref)?;
406 }
407 let dir = cache_dir_for(&opts.cache_root, url, git_ref);
408 let is_repo = dir.join(".git").is_dir();
409
410 if !is_repo {
411 if opts.offline {
412 return Err(SourceError::OfflineNoCache {
413 url: sanitize_control(url).into_owned(),
414 path: sanitize_control(&dir.display().to_string()).into_owned(),
415 });
416 }
417 create_dir_all(&dir).map_err(|source| SourceError::Cache {
418 path: dir.display().to_string(),
419 source,
420 })?;
421 let init = run_git(&dir, &["init", "--quiet"], &opts.allowed_protocols)?;
422 if !init.status.success() {
423 return Err(SourceError::Git {
424 url: sanitize_control(url).into_owned(),
425 args: "init".to_string(),
426 stderr: stderr_of(&init),
427 });
428 }
429 fetch_and_checkout(&dir, url, git_ref, opts)?;
430 } else if !opts.offline {
431 let pinned_present = git_ref.is_some_and(|sha| {
432 is_pinned_sha(sha) && is_checked_out(&dir, sha, &opts.allowed_protocols)
433 });
434 if !pinned_present {
435 fetch_and_checkout(&dir, url, git_ref, opts)?;
436 }
437 }
438
439 Ok(dir)
440}
441
442pub fn resolve_sources(
448 sources: &[RuleSource],
449 opts: &SourceOptions,
450) -> Result<Vec<ResolvedSource>, SourceError> {
451 let mut resolved = Vec::with_capacity(sources.len());
452 for source in sources {
453 if let Some(url) = &source.git {
454 let repo = ensure_git_source(url, source.git_ref.as_deref(), opts)?;
455 let dir = match &source.path {
456 Some(sub) => repo.join(sub),
457 None => repo,
458 };
459 resolved.push(ResolvedSource {
460 dir,
461 origin: RuleOrigin::Git { url: url.clone() },
462 });
463 } else if let Some(path) = &source.path {
464 if !path.is_dir() {
465 return Err(SourceError::LocalMissing {
466 path: sanitize_control(&path.display().to_string()).into_owned(),
467 });
468 }
469 resolved.push(ResolvedSource {
470 dir: path.clone(),
471 origin: RuleOrigin::Local { path: path.clone() },
472 });
473 }
474 }
475 Ok(resolved)
476}
477
478#[cfg(test)]
479mod tests;