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//! External sources are untrusted input, so every git call is hardened:
7//!
8//! - A URL or ref starting with `-` is rejected before git runs (git would
9//!   parse it as an option such as `--upload-pack=...`), and the fetch passes
10//!   `--` before its positional arguments.
11//! - `GIT_ALLOW_PROTOCOL` restricts git to `https` and `ssh` (no `file://`,
12//!   `ext::` or other transports). `SLOPGUARD_GIT_ALLOW_PROTOCOL` overrides the
13//!   list; it exists for tests and CI that serve rules from `file://` remotes.
14//! - A ref that is not a full 40-character commit sha is floating: the rules
15//!   it loads can change under the user, so the CLI warns about it
16//!   ([`unpinned_source_warnings`]). A pinned sha already checked out in the
17//!   cache is reused without touching the network.
18//! - Refreshing an existing cache when online must succeed: a failed fetch
19//!   fails the resolution instead of silently scanning with stale rules.
20//!
21//! Git auth is delegated to the machine's own git (ssh-agent, credential
22//! helper, `~/.gitconfig`). `SLOPGUARD_GIT_TOKEN` overrides that for https URLs
23//! of a single trusted host (`SLOPGUARD_GIT_TOKEN_HOST`, else `[git]
24//! token_host` in the user config): the token is injected into the transient
25//! `git fetch <url>` argument only, never into the persisted remote, so no
26//! token is written to disk. Without a configured host, no token is injected.
27
28use 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
40/// Environment variable holding an https token that overrides git's own
41/// credential resolution. Empty means "unset".
42const GIT_TOKEN_ENV: &str = "SLOPGUARD_GIT_TOKEN";
43
44/// Environment variable naming the only host the token may be sent to. Takes
45/// precedence over `[git] token_host`. Empty means "unset".
46const GIT_TOKEN_HOST_ENV: &str = "SLOPGUARD_GIT_TOKEN_HOST";
47
48/// Environment override of the protocols git may use for sources, in
49/// `GIT_ALLOW_PROTOCOL` syntax (colon-separated). Meant for tests and CI that
50/// serve rules from `file://` remotes. Empty means "unset".
51const ALLOW_PROTOCOL_ENV: &str = "SLOPGUARD_GIT_ALLOW_PROTOCOL";
52
53/// Protocols git may use to fetch a source unless overridden.
54pub const DEFAULT_ALLOWED_PROTOCOLS: &str = "https:ssh";
55
56const HTTPS_PREFIX: &str = "https://";
57
58/// Replacement for secrets in error text.
59const REDACTED: &str = "<redacted>";
60
61/// An https token together with the single host it may be sent to.
62#[derive(Clone, PartialEq, Eq)]
63pub struct GitToken {
64    /// The secret, injected as `oauth2:<token>@` basic auth.
65    pub token: String,
66    /// The only host (port excluded, compared case-insensitively) that may
67    /// receive the token.
68    pub host: String,
69}
70
71impl fmt::Debug for GitToken {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        // The secret never appears in debug output.
74        f.debug_struct("GitToken")
75            .field("token", &REDACTED)
76            .field("host", &self.host)
77            .finish()
78    }
79}
80
81/// Read the git token from `SLOPGUARD_GIT_TOKEN`, bound to the host named by
82/// `SLOPGUARD_GIT_TOKEN_HOST`, else `config_host` (`[git] token_host`).
83///
84/// Returns `None` when the token or the host is missing: a token is never sent
85/// to a host the user did not name.
86pub 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
93/// The protocols git may use for sources: `$SLOPGUARD_GIT_ALLOW_PROTOCOL` when
94/// set, otherwise [`DEFAULT_ALLOWED_PROTOCOLS`].
95pub 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/// Where a rule came from, tracked so `slopguard list` can show provenance.
104#[derive(Debug, Clone, PartialEq, Eq, Default)]
105pub enum RuleOrigin {
106    /// Embedded in the binary.
107    #[default]
108    Builtin,
109    /// A git source, identified by its clone URL.
110    Git { url: String },
111    /// A local directory (`rules.custom_dirs` or a local `[[rules.sources]]`).
112    Local { path: PathBuf },
113}
114
115impl RuleOrigin {
116    /// The `source` string shown by `slopguard list`: `"builtin"`, the git URL,
117    /// or the local path.
118    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/// A source resolved to a directory on disk plus the provenance of its rules.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct ResolvedSource {
130    /// Directory to load rule YAML from.
131    pub dir: PathBuf,
132    /// Provenance to stamp on every rule loaded from `dir`.
133    pub origin: RuleOrigin,
134}
135
136/// How git sources are cached, fetched and authenticated.
137#[derive(Debug, Clone)]
138pub struct SourceOptions {
139    /// Base directory holding one sub-directory per cached git source.
140    pub cache_root: PathBuf,
141    /// Never touch the network: reuse the cache, and fail if it is missing.
142    pub offline: bool,
143    /// Token injected into https fetches of its host only.
144    pub git_token: Option<GitToken>,
145    /// `GIT_ALLOW_PROTOCOL` value set on every git call (colon-separated).
146    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
160/// Environment override for the git source cache root, taking precedence over
161/// the default user cache location. Useful in CI and tests.
162const CACHE_ENV: &str = "SLOPGUARD_SOURCES_CACHE";
163
164/// Default cache location for git sources: `$SLOPGUARD_SOURCES_CACHE` when set,
165/// otherwise `<user cache>/slopguard/sources`.
166pub 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
210/// Whether `git_ref` is a full 40-character hex commit sha, i.e. a pinned ref
211/// whose content cannot change.
212pub fn is_pinned_sha(git_ref: &str) -> bool {
213    git_ref.len() == 40 && git_ref.bytes().all(|b| b.is_ascii_hexdigit())
214}
215
216/// One warning per git source whose ref is missing or is not a pinned commit
217/// sha. The URL is printed without its userinfo, so no credential leaks.
218pub 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
236/// The authority of a `scheme://authority/path` URL, or `None` without `://`.
237fn url_authority(url: &str) -> Option<&str> {
238    let (_, rest) = url.split_once("://")?;
239    Some(rest.split('/').next().unwrap_or(rest))
240}
241
242/// The host of a URL: the authority after its last `@` (userinfo is never the
243/// host), without `:port`. `https://trusted.com@evil.com/x` yields `evil.com`.
244fn 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
250/// `url` with any userinfo replaced by a placeholder, for messages.
251fn 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
265/// The cache sub-directory for a git source, keyed by a hash of its URL and
266/// ref so two refs of the same repository never share a checkout.
267fn 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
279/// The URL passed to a transient `git fetch`. The token is injected as basic
280/// auth only for an https URL without userinfo whose host is the token's host;
281/// otherwise the URL is returned unchanged and git resolves credentials itself.
282fn 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
294/// Reject a URL or ref that git would parse as an option (`--upload-pack=...`).
295fn 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
305/// Run git in `dir`, restricted to the `allowed_protocols` transports.
306fn 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
319/// Shallow-fetch `git_ref` from `url` into an existing repo at `dir` and check
320/// it out detached. The token (if any) lives only in this fetch argument, never
321/// in the repo's persisted remote, and is redacted from the error text.
322fn 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
370/// Whether the cached repo at `dir` already has the pinned `sha` checked out.
371fn 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
379/// Ensure a git source is present in the cache and checked out at `git_ref`,
380/// returning the repository directory.
381///
382/// The URL and ref are validated before any git process runs. A missing cache
383/// is cloned (unless offline). An existing cache is refreshed when online, and
384/// a failed refresh is an error: scanning with stale rules would hide that the
385/// source can no longer be verified. A pinned sha already checked out is
386/// reused as is, without touching the network.
387fn 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
431/// Resolve every configured source to a directory and its provenance.
432///
433/// Sources are validated at config load, so each entry has a `git` URL or a
434/// local `path`. Git sources are cloned/refreshed under `opts.cache_root`;
435/// local sources must already exist.
436pub 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;