1#![allow(clippy::module_name_repetitions)]
31
32#[cfg(test)]
33use std::path::PathBuf;
34use std::time::Duration;
35
36use thiserror::Error;
37
38mod cache;
39mod fetch;
40mod parse;
41
42pub use cache::{Cache, FileCache, MemoryCache};
43pub use parse::Source;
44
45#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct Resolved {
48 pub source: Source,
49 pub bytes: Vec<u8>,
50 pub blake3: String, }
52
53#[derive(Debug, Error)]
54pub enum ResolveError {
55 #[error("malformed source URL: {0}")]
56 BadUrl(String),
57
58 #[error("HTTP error fetching {url}: {status}")]
59 Http { url: String, status: u16 },
60
61 #[error("network error fetching {url}: {source}")]
62 Network {
63 url: String,
64 #[source]
65 source: Box<dyn std::error::Error + Send + Sync>,
66 },
67
68 #[error("file I/O error on {path}: {source}")]
69 Io {
70 path: String,
71 #[source]
72 source: std::io::Error,
73 },
74
75 #[error("blake3 mismatch on {url}: declared={declared:?} actual={actual}")]
76 HashMismatch {
77 url: String,
78 declared: String,
79 actual: String,
80 },
81}
82
83pub struct Resolver<C: Cache> {
85 cache: C,
86 timeout: Duration,
87 user_agent: String,
88}
89
90impl<C: Cache> Resolver<C> {
91 pub fn new(cache: C) -> Self {
92 Self {
93 cache,
94 timeout: Duration::from_secs(30),
95 user_agent: format!("tatara-lisp-source/{}", env!("CARGO_PKG_VERSION")),
96 }
97 }
98
99 pub fn timeout(mut self, t: Duration) -> Self {
100 self.timeout = t;
101 self
102 }
103
104 pub fn user_agent<S: Into<String>>(mut self, ua: S) -> Self {
105 self.user_agent = ua.into();
106 self
107 }
108
109 pub fn resolve(&mut self, url: &str) -> Result<Resolved, ResolveError> {
111 let source = Source::parse(url)?;
112 self.resolve_source(&source)
113 }
114
115 pub fn resolve_source(&mut self, source: &Source) -> Result<Resolved, ResolveError> {
116 let cache_key = source.cache_key();
119 if let Some(cached) = self.cache.get(&cache_key) {
120 return Ok(Resolved {
121 source: source.clone(),
122 blake3: blake3_hex(&cached),
123 bytes: cached,
124 });
125 }
126
127 let bytes = fetch::fetch(source, self.timeout, &self.user_agent)?;
128 let actual = blake3_hex(&bytes);
129
130 if let Some(declared) = source.declared_blake3() {
132 if declared != actual {
133 return Err(ResolveError::HashMismatch {
134 url: source.to_string(),
135 declared: declared.into(),
136 actual,
137 });
138 }
139 }
140
141 self.cache.put(cache_key, bytes.clone());
142 Ok(Resolved {
143 source: source.clone(),
144 bytes,
145 blake3: actual,
146 })
147 }
148}
149
150#[must_use]
152pub fn blake3_hex(bytes: &[u8]) -> String {
153 blake3::hash(bytes).to_hex().to_string()
154}
155
156pub fn resolve_once(url: &str) -> Result<Resolved, ResolveError> {
159 let mut r = Resolver::new(MemoryCache::default());
160 r.resolve(url)
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn parse_local_path() {
169 let s = Source::parse("./local/path.tlisp").unwrap();
170 match s {
171 Source::Local { path } => assert_eq!(path, PathBuf::from("./local/path.tlisp")),
172 other => panic!("expected Local, got {other:?}"),
173 }
174 }
175
176 #[test]
177 fn parse_github_basic() {
178 let s = Source::parse("github:pleme-io/programs/dns-reconciler/main.tlisp").unwrap();
179 match s {
180 Source::GitHub {
181 owner,
182 repo,
183 path,
184 rev,
185 } => {
186 assert_eq!(owner, "pleme-io");
187 assert_eq!(repo, "programs");
188 assert_eq!(path, PathBuf::from("dns-reconciler/main.tlisp"));
189 assert_eq!(rev, None);
190 }
191 other => panic!("expected GitHub, got {other:?}"),
192 }
193 }
194
195 #[test]
196 fn parse_github_with_ref() {
197 let s = Source::parse("github:pleme-io/programs/pvc-autoresizer/main.tlisp?ref=v0.1.0")
198 .unwrap();
199 match s {
200 Source::GitHub { rev, .. } => assert_eq!(rev.as_deref(), Some("v0.1.0")),
201 other => panic!("expected GitHub, got {other:?}"),
202 }
203 }
204
205 #[test]
206 fn parse_https_with_blake3_pin() {
207 let s = Source::parse("https://example.com/program.tlisp#blake3=abc123").unwrap();
208 match s {
209 Source::HttpDirect { url, blake3 } => {
210 assert_eq!(url, "https://example.com/program.tlisp");
211 assert_eq!(blake3.as_deref(), Some("abc123"));
212 }
213 other => panic!("expected HttpDirect, got {other:?}"),
214 }
215 }
216
217 #[test]
218 fn parse_gitlab_codeberg() {
219 let g = Source::parse("gitlab:foo/bar/baz.tlisp?ref=main").unwrap();
220 match g {
221 Source::GitLab { owner, repo, .. } => {
222 assert_eq!(owner, "foo");
223 assert_eq!(repo, "bar");
224 }
225 _ => panic!("expected GitLab"),
226 }
227 let c = Source::parse("codeberg:foo/bar/baz.tlisp").unwrap();
228 match c {
229 Source::Codeberg { owner, .. } => assert_eq!(owner, "foo"),
230 _ => panic!("expected Codeberg"),
231 }
232 }
233
234 #[test]
235 fn malformed_url_rejected() {
236 assert!(Source::parse("github:incomplete").is_err());
237 assert!(Source::parse("github:").is_err());
238 assert!(Source::parse("nonsense::").is_err());
239 }
240
241 #[test]
242 fn local_path_fetch() {
243 let dir = tempfile::tempdir().unwrap();
244 let path = dir.path().join("hello.tlisp");
245 std::fs::write(&path, b"(println \"hi\")").unwrap();
246
247 let mut r = Resolver::new(MemoryCache::default());
248 let resolved = r
249 .resolve(path.to_str().unwrap())
250 .expect("local path should resolve");
251 assert_eq!(resolved.bytes, b"(println \"hi\")");
252 assert_eq!(resolved.blake3, blake3_hex(b"(println \"hi\")"));
253 }
254
255 #[test]
256 fn cache_hits_on_second_resolve() {
257 let dir = tempfile::tempdir().unwrap();
258 let path = dir.path().join("hello.tlisp");
259 std::fs::write(&path, b"(+ 1 2)").unwrap();
260
261 let mut r = Resolver::new(MemoryCache::default());
262 let first = r.resolve(path.to_str().unwrap()).unwrap();
263
264 std::fs::write(&path, b"different").unwrap();
267 let second = r.resolve(path.to_str().unwrap()).unwrap();
268
269 assert_eq!(first.bytes, second.bytes);
270 assert_eq!(first.blake3, second.blake3);
271 }
272
273 #[test]
274 fn blake3_pin_mismatch_errors() {
275 let dir = tempfile::tempdir().unwrap();
276 let path = dir.path().join("hello.tlisp");
277 std::fs::write(&path, b"hello").unwrap();
278 let actual = blake3_hex(b"hello");
279
280 let url = format!("https://example.invalid/x#blake3=deadbeef");
282 let s = Source::HttpDirect {
283 url,
284 blake3: Some("deadbeef".into()),
285 };
286
287 let _ = actual; let s2 = Source::Local { path: path.clone() };
292 let mut r = Resolver::new(MemoryCache::default());
293 let r1 = r.resolve_source(&s2).unwrap();
294 assert_eq!(r1.blake3, blake3_hex(b"hello"));
295
296 match s {
299 Source::HttpDirect { blake3, .. } => assert_eq!(blake3.as_deref(), Some("deadbeef")),
300 _ => unreachable!(),
301 }
302 }
303}