1use std::fs::create_dir_all;
12use std::io::Error as IoError;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Output};
15
16use sha2::{Digest, Sha256};
17use thiserror::Error;
18
19use crate::config::RuleSource;
20
21const GIT_TOKEN_ENV: &str = "SLOPGUARD_GIT_TOKEN";
24
25const HTTPS_PREFIX: &str = "https://";
26
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub enum RuleOrigin {
30 #[default]
32 Builtin,
33 Git { url: String },
35 Local { path: PathBuf },
37}
38
39impl RuleOrigin {
40 pub fn label(&self) -> String {
43 match self {
44 RuleOrigin::Builtin => "builtin".to_string(),
45 RuleOrigin::Git { url } => url.clone(),
46 RuleOrigin::Local { path } => path.display().to_string(),
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ResolvedSource {
54 pub dir: PathBuf,
56 pub origin: RuleOrigin,
58}
59
60#[derive(Debug, Clone)]
62pub struct SourceOptions {
63 pub cache_root: PathBuf,
65 pub offline: bool,
67}
68
69impl Default for SourceOptions {
70 fn default() -> Self {
71 Self {
72 cache_root: default_cache_root(),
73 offline: false,
74 }
75 }
76}
77
78const CACHE_ENV: &str = "SLOPGUARD_SOURCES_CACHE";
81
82pub fn default_cache_root() -> PathBuf {
85 if let Ok(dir) = std::env::var(CACHE_ENV) {
86 if !dir.is_empty() {
87 return PathBuf::from(dir);
88 }
89 }
90 dirs::cache_dir()
91 .unwrap_or_else(|| PathBuf::from(".cache"))
92 .join("slopguard")
93 .join("sources")
94}
95
96#[derive(Debug, Error)]
97pub enum SourceError {
98 #[error("failed to run git (is it installed and on PATH?): {source}")]
99 Spawn {
100 #[source]
101 source: IoError,
102 },
103
104 #[error("git {args} failed for source '{url}': {stderr}")]
105 Git {
106 url: String,
107 args: String,
108 stderr: String,
109 },
110
111 #[error("offline: no cached clone for git source '{url}' (expected at '{path}')")]
112 OfflineNoCache { url: String, path: String },
113
114 #[error("failed to prepare cache directory '{path}': {source}")]
115 Cache {
116 path: String,
117 #[source]
118 source: IoError,
119 },
120
121 #[error("local rule source '{path}' does not exist or is not a directory")]
122 LocalMissing { path: String },
123}
124
125fn cache_dir_for(root: &Path, url: &str, git_ref: Option<&str>) -> PathBuf {
128 let mut hasher = Sha256::new();
129 hasher.update(url.as_bytes());
130 hasher.update([0u8]);
131 if let Some(git_ref) = git_ref {
132 hasher.update(git_ref.as_bytes());
133 }
134 let digest = hasher.finalize();
135 let hex: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
136 root.join(hex)
137}
138
139fn fetch_url(url: &str) -> String {
143 match std::env::var(GIT_TOKEN_ENV) {
144 Ok(token) if !token.is_empty() && url.starts_with(HTTPS_PREFIX) => {
145 format!(
146 "{HTTPS_PREFIX}oauth2:{token}@{}",
147 &url[HTTPS_PREFIX.len()..]
148 )
149 }
150 _ => url.to_string(),
151 }
152}
153
154fn run_git(dir: &Path, args: &[&str]) -> Result<Output, SourceError> {
155 Command::new("git")
156 .current_dir(dir)
157 .args(args)
158 .output()
159 .map_err(|source| SourceError::Spawn { source })
160}
161
162fn stderr_of(output: &Output) -> String {
163 String::from_utf8_lossy(&output.stderr).trim().to_string()
164}
165
166fn fetch_and_checkout(dir: &Path, url: &str, git_ref: Option<&str>) -> Result<(), SourceError> {
170 let refspec = git_ref.unwrap_or("HEAD");
171 let tokened = fetch_url(url);
172 let fetch = run_git(dir, &["fetch", "--depth", "1", &tokened, refspec])?;
173 if !fetch.status.success() {
174 return Err(SourceError::Git {
175 url: url.to_string(),
176 args: format!("fetch {refspec}"),
177 stderr: stderr_of(&fetch),
178 });
179 }
180 let checkout = run_git(
181 dir,
182 &[
183 "-c",
184 "advice.detachedHead=false",
185 "checkout",
186 "--force",
187 "FETCH_HEAD",
188 ],
189 )?;
190 if !checkout.status.success() {
191 return Err(SourceError::Git {
192 url: url.to_string(),
193 args: "checkout FETCH_HEAD".to_string(),
194 stderr: stderr_of(&checkout),
195 });
196 }
197 Ok(())
198}
199
200fn ensure_git_source(
207 url: &str,
208 git_ref: Option<&str>,
209 cache_root: &Path,
210 offline: bool,
211) -> Result<PathBuf, SourceError> {
212 let dir = cache_dir_for(cache_root, url, git_ref);
213 let is_repo = dir.join(".git").is_dir();
214
215 if !is_repo {
216 if offline {
217 return Err(SourceError::OfflineNoCache {
218 url: url.to_string(),
219 path: dir.display().to_string(),
220 });
221 }
222 create_dir_all(&dir).map_err(|source| SourceError::Cache {
223 path: dir.display().to_string(),
224 source,
225 })?;
226 let init = run_git(&dir, &["init", "--quiet"])?;
227 if !init.status.success() {
228 return Err(SourceError::Git {
229 url: url.to_string(),
230 args: "init".to_string(),
231 stderr: stderr_of(&init),
232 });
233 }
234 fetch_and_checkout(&dir, url, git_ref)?;
235 } else if !offline {
236 let _ = fetch_and_checkout(&dir, url, git_ref);
240 }
241
242 Ok(dir)
243}
244
245pub fn resolve_sources(
251 sources: &[RuleSource],
252 opts: &SourceOptions,
253) -> Result<Vec<ResolvedSource>, SourceError> {
254 let mut resolved = Vec::with_capacity(sources.len());
255 for source in sources {
256 if let Some(url) = &source.git {
257 let repo = ensure_git_source(
258 url,
259 source.git_ref.as_deref(),
260 &opts.cache_root,
261 opts.offline,
262 )?;
263 let dir = match &source.path {
264 Some(sub) => repo.join(sub),
265 None => repo,
266 };
267 resolved.push(ResolvedSource {
268 dir,
269 origin: RuleOrigin::Git { url: url.clone() },
270 });
271 } else if let Some(path) = &source.path {
272 if !path.is_dir() {
273 return Err(SourceError::LocalMissing {
274 path: path.display().to_string(),
275 });
276 }
277 resolved.push(ResolvedSource {
278 dir: path.clone(),
279 origin: RuleOrigin::Local { path: path.clone() },
280 });
281 }
282 }
283 Ok(resolved)
284}
285
286#[cfg(test)]
287mod tests;