difflore_core/packs/
registry.rs1use std::time::Duration;
8
9use crate::packs::manifest::{PackIndex, PackManifest, manifest_sha256};
10
11pub const DEFAULT_PACK_REGISTRY: &str =
14 "https://raw.githubusercontent.com/difflore/rule-packs/main";
15
16const FETCH_TIMEOUT: Duration = Duration::from_secs(20);
19
20const MAX_REDIRECTS: usize = 4;
22
23#[derive(Debug)]
24pub enum PackFetchError {
25 BadUrl(String),
27 Transport(String),
29 Status { url: String, status: u16 },
31 Io(String),
33 Parse(String),
35 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#[allow(dead_code)]
65fn is_file_registry(base: &str) -> bool {
66 base.starts_with("file://")
67}
68
69fn join_url(base: &str, rel: &str) -> String {
72 format!(
73 "{}/{}",
74 base.trim_end_matches('/'),
75 rel.trim_start_matches('/')
76 )
77}
78
79async fn get_bytes(url: &str) -> Result<Vec<u8>, PackFetchError> {
81 if let Some(path) = url.strip_prefix("file://") {
82 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
116pub 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
127pub 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 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#[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 let err = fetch_manifest(&base, "pack.json", "0000")
210 .await
211 .expect_err("should refuse");
212 assert!(matches!(err, PackFetchError::IntegrityMismatch { .. }));
213 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}