Skip to main content

difflore_core/packs/
registry.rs

1//! Registry transport for rule packs (roadmap §6): fetch `index.json` and a
2//! pack's `pack.json` over HTTPS (with a short timeout + small redirect cap,
3//! mirroring `cloud/mod.rs`'s reqwest client) or from a `file://` path for
4//! tests / air-gapped install. No DiffLore Cloud dependency — install is a pure
5//! GET of public content.
6
7use std::time::Duration;
8
9use crate::packs::manifest::{PackIndex, PackManifest, manifest_sha256};
10
11/// Raw GitHub content of the registry repo's default branch. The `--registry`
12/// CLI flag overrides this with a fork, a private mirror, or a `file://` path.
13pub const DEFAULT_PACK_REGISTRY: &str =
14    "https://raw.githubusercontent.com/difflore/rule-packs/main";
15
16/// HTTP request timeout. Short, matching the cloud client's posture — a hung
17/// registry must not stall `packs list`.
18const FETCH_TIMEOUT: Duration = Duration::from_secs(20);
19
20/// Cap redirects so a malicious registry can't bounce the client around.
21const MAX_REDIRECTS: usize = 4;
22
23#[derive(Debug)]
24pub enum PackFetchError {
25    /// The registry base URL or a derived path was malformed.
26    BadUrl(String),
27    /// Could not build the HTTP client or reach the registry.
28    Transport(String),
29    /// The registry returned a non-success HTTP status.
30    Status { url: String, status: u16 },
31    /// A `file://` registry path could not be read.
32    Io(String),
33    /// The fetched bytes did not parse as the expected JSON shape.
34    Parse(String),
35    /// The fetched manifest's `sha256` did not match the index pin.
36    IntegrityMismatch { expected: String, actual: String },
37}
38
39impl std::fmt::Display for PackFetchError {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            Self::BadUrl(m) => write!(f, "invalid registry URL: {m}"),
43            Self::Transport(m) => write!(f, "could not reach registry: {m}"),
44            Self::Status { url, status } => {
45                write!(f, "registry returned HTTP {status} for {url}")
46            }
47            Self::Io(m) => write!(f, "could not read local registry path: {m}"),
48            Self::Parse(m) => write!(f, "registry payload did not parse: {m}"),
49            Self::IntegrityMismatch { expected, actual } => write!(
50                f,
51                "pack manifest failed integrity check (sha256 expected {expected}, got {actual}) \
52                 — refusing to install"
53            ),
54        }
55    }
56}
57
58impl std::error::Error for PackFetchError {}
59
60/// Whether the registry base points at a local `file://` path (tests /
61/// air-gapped install) rather than an HTTP(S) endpoint. The live fetch path
62/// (`get_bytes`) inlines this check via `strip_prefix("file://")`; this named
63/// predicate documents the contract and is exercised by the unit test below.
64#[allow(dead_code)]
65fn is_file_registry(base: &str) -> bool {
66    base.starts_with("file://")
67}
68
69/// Join a registry base URL with a relative path, normalising the single slash
70/// between them. Works for both HTTP bases and `file://` bases.
71fn join_url(base: &str, rel: &str) -> String {
72    format!(
73        "{}/{}",
74        base.trim_end_matches('/'),
75        rel.trim_start_matches('/')
76    )
77}
78
79/// Read raw bytes from either an HTTP(S) URL or a `file://` path.
80async fn get_bytes(url: &str) -> Result<Vec<u8>, PackFetchError> {
81    if let Some(path) = url.strip_prefix("file://") {
82        // Tolerate the Windows `file:///C:/...` shape: strip a single leading
83        // slash that precedes a drive letter so `C:/...` round-trips.
84        let path = path
85            .strip_prefix('/')
86            .filter(|p| p.as_bytes().get(1) == Some(&b':'))
87            .unwrap_or(path);
88        return tokio::fs::read(path)
89            .await
90            .map_err(|e| PackFetchError::Io(format!("{path}: {e}")));
91    }
92
93    let client = reqwest::Client::builder()
94        .timeout(FETCH_TIMEOUT)
95        .redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS))
96        .build()
97        .map_err(|e| PackFetchError::Transport(format!("could not build HTTP client: {e}")))?;
98    let resp = client
99        .get(url)
100        .send()
101        .await
102        .map_err(|e| PackFetchError::Transport(e.to_string()))?;
103    let status = resp.status();
104    if !status.is_success() {
105        return Err(PackFetchError::Status {
106            url: url.to_owned(),
107            status: status.as_u16(),
108        });
109    }
110    resp.bytes()
111        .await
112        .map(|b| b.to_vec())
113        .map_err(|e| PackFetchError::Transport(e.to_string()))
114}
115
116/// Fetch and parse the registry `index.json`.
117pub async fn fetch_index(registry_base: &str) -> Result<PackIndex, PackFetchError> {
118    let base = registry_base.trim();
119    if base.is_empty() {
120        return Err(PackFetchError::BadUrl("empty registry base".to_owned()));
121    }
122    let url = join_url(base, "index.json");
123    let bytes = get_bytes(&url).await?;
124    serde_json::from_slice(&bytes).map_err(|e| PackFetchError::Parse(format!("index.json: {e}")))
125}
126
127/// Fetch a pack `pack.json`, verify its `sha256` against the index pin, and
128/// parse it. `manifest_rel` is the index-declared path; `expected_sha256` is the
129/// pin. Refuses to return a manifest whose bytes don't match the pin.
130pub async fn fetch_manifest(
131    registry_base: &str,
132    manifest_rel: &str,
133    expected_sha256: &str,
134) -> Result<PackManifest, PackFetchError> {
135    let base = registry_base.trim();
136    if base.is_empty() {
137        return Err(PackFetchError::BadUrl("empty registry base".to_owned()));
138    }
139    let url = join_url(base, manifest_rel);
140    let bytes = get_bytes(&url).await?;
141
142    // Supply-chain guard: recompute over the fetched bytes and refuse on
143    // mismatch BEFORE parsing, so a tampered manifest never reaches install.
144    let actual = manifest_sha256(&bytes);
145    let expected = expected_sha256.trim().to_ascii_lowercase();
146    if !expected.is_empty() && actual != expected {
147        return Err(PackFetchError::IntegrityMismatch { expected, actual });
148    }
149
150    serde_json::from_slice(&bytes)
151        .map_err(|e| PackFetchError::Parse(format!("{manifest_rel}: {e}")))
152}
153
154/// Whether a `--registry` override points at the first-party default. Callers
155/// use this to demote a `maintainer.verified` badge to "verified (custom
156/// registry)" so the trust signal is never misleading.
157#[must_use]
158pub fn is_default_registry(registry_base: &str) -> bool {
159    registry_base.trim().trim_end_matches('/') == DEFAULT_PACK_REGISTRY
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn join_url_normalises_slashes() {
168        assert_eq!(
169            join_url("https://example.com/reg/", "/index.json"),
170            "https://example.com/reg/index.json"
171        );
172        assert_eq!(
173            join_url("https://example.com/reg", "packs/a/pack.json"),
174            "https://example.com/reg/packs/a/pack.json"
175        );
176    }
177
178    #[test]
179    fn detects_file_and_default_registries() {
180        assert!(is_file_registry("file:///tmp/reg"));
181        assert!(!is_file_registry("https://example.com"));
182        assert!(is_default_registry(DEFAULT_PACK_REGISTRY));
183        assert!(is_default_registry(&format!("{DEFAULT_PACK_REGISTRY}/")));
184        assert!(!is_default_registry("https://example.com/fork"));
185    }
186
187    #[tokio::test]
188    async fn file_registry_round_trips_index() {
189        let dir = tempfile::tempdir().expect("tempdir");
190        let index_path = dir.path().join("index.json");
191        std::fs::write(
192            &index_path,
193            r#"{"schemaVersion":1,"packs":[{"id":"x/y","name":"Y","latest":"1.0.0","versions":{}}]}"#,
194        )
195        .expect("write");
196        let base = format!("file://{}", dir.path().display());
197        let index = fetch_index(&base).await.expect("fetch index");
198        assert_eq!(index.packs.len(), 1);
199        assert_eq!(index.packs[0].id, "x/y");
200    }
201
202    #[tokio::test]
203    async fn manifest_integrity_mismatch_is_refused() {
204        let dir = tempfile::tempdir().expect("tempdir");
205        let raw = r#"{"schemaVersion":1,"id":"x/y","name":"Y","version":"1.0.0","rules":[]}"#;
206        std::fs::write(dir.path().join("pack.json"), raw).expect("write");
207        let base = format!("file://{}", dir.path().display());
208        // Wrong pin -> refused.
209        let err = fetch_manifest(&base, "pack.json", "0000")
210            .await
211            .expect_err("should refuse");
212        assert!(matches!(err, PackFetchError::IntegrityMismatch { .. }));
213        // Correct pin -> parses.
214        let good = manifest_sha256(raw.as_bytes());
215        let manifest = fetch_manifest(&base, "pack.json", &good)
216            .await
217            .expect("fetch manifest");
218        assert_eq!(manifest.id, "x/y");
219    }
220}