Skip to main content

slopguard_core/source/
mod.rs

1//! External rule sources: git repositories cloned into a local cache, plus
2//! extra local directories. Each configured `[[rules.sources]]` is resolved to
3//! a directory of rule YAML and the provenance stamped on the rules loaded
4//! from it.
5//!
6//! Git auth is delegated to the machine's own git (ssh-agent, credential
7//! helper, `~/.gitconfig`). `SLOPGUARD_GIT_TOKEN` overrides that for https URLs
8//! by injecting the token into the transient `git fetch <url>` argument only,
9//! never into the persisted remote, so no token is written to disk.
10
11use 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
21/// Environment variable holding an https token that overrides git's own
22/// credential resolution. Empty means "unset".
23const GIT_TOKEN_ENV: &str = "SLOPGUARD_GIT_TOKEN";
24
25const HTTPS_PREFIX: &str = "https://";
26
27/// Where a rule came from, tracked so `slopguard list` can show provenance.
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub enum RuleOrigin {
30    /// Embedded in the binary.
31    #[default]
32    Builtin,
33    /// A git source, identified by its clone URL.
34    Git { url: String },
35    /// A local directory (`rules.custom_dirs` or a local `[[rules.sources]]`).
36    Local { path: PathBuf },
37}
38
39impl RuleOrigin {
40    /// The `source` string shown by `slopguard list`: `"builtin"`, the git URL,
41    /// or the local path.
42    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/// A source resolved to a directory on disk plus the provenance of its rules.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ResolvedSource {
54    /// Directory to load rule YAML from.
55    pub dir: PathBuf,
56    /// Provenance to stamp on every rule loaded from `dir`.
57    pub origin: RuleOrigin,
58}
59
60/// How git sources are cached and whether the network may be used.
61#[derive(Debug, Clone)]
62pub struct SourceOptions {
63    /// Base directory holding one sub-directory per cached git source.
64    pub cache_root: PathBuf,
65    /// Never touch the network: reuse the cache, and fail if it is missing.
66    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
78/// Environment override for the git source cache root, taking precedence over
79/// the default user cache location. Useful in CI and tests.
80const CACHE_ENV: &str = "SLOPGUARD_SOURCES_CACHE";
81
82/// Default cache location for git sources: `$SLOPGUARD_SOURCES_CACHE` when set,
83/// otherwise `<user cache>/slopguard/sources`.
84pub 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
125/// The cache sub-directory for a git source, keyed by a hash of its URL and
126/// ref so two refs of the same repository never share a checkout.
127fn 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
139/// The URL passed to a transient `git fetch`. When `SLOPGUARD_GIT_TOKEN` is set
140/// and the URL is https, the token is injected as basic auth; otherwise the URL
141/// is returned unchanged and git resolves credentials itself.
142fn 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
166/// Shallow-fetch `git_ref` from `url` into an existing repo at `dir` and check
167/// it out detached. The token (if any) lives only in this fetch argument, never
168/// in the repo's persisted remote.
169fn 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
200/// Ensure a git source is present in the cache and checked out at `git_ref`,
201/// returning the repository directory.
202///
203/// A missing cache is cloned (unless offline). An existing cache is refreshed
204/// best-effort when online: a failed refresh keeps the cached checkout, so a
205/// scan does not break when the remote is unreachable.
206fn 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        // Refresh best-effort: a moving branch is picked up, but an unreachable
237        // remote falls back to the existing checkout, so a scan does not break.
238        // slopguard-disable-next-line no-ignored-result
239        let _ = fetch_and_checkout(&dir, url, git_ref);
240    }
241
242    Ok(dir)
243}
244
245/// Resolve every configured source to a directory and its provenance.
246///
247/// Sources are validated at config load, so each entry has a `git` URL or a
248/// local `path`. Git sources are cloned/refreshed under `opts.cache_root`;
249/// local sources must already exist.
250pub 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;