Skip to main content

pacsea/sources/
pkgbuild.rs

1//! PKGBUILD fetching with rate limiting and caching.
2
3use crate::logic::files::get_pkgbuild_from_cache;
4use crate::state::{PackageItem, Source};
5use crate::util::percent_encode;
6use std::sync::Mutex;
7use std::time::{Duration, Instant};
8
9/// Result type alias for PKGBUILD fetching operations.
10type Result<T> = super::Result<T>;
11
12/// Rate limiter for PKGBUILD requests to avoid overwhelming AUR servers.
13///
14/// Tracks the timestamp of the last PKGBUILD request to enforce minimum intervals.
15static PKGBUILD_RATE_LIMITER: Mutex<Option<Instant>> = Mutex::new(None);
16/// Minimum interval between PKGBUILD requests in milliseconds.
17///
18/// Reduced from 500ms to 200ms for faster preview operations.
19const PKGBUILD_MIN_INTERVAL_MS: u64 = 200;
20/// Extra curl flags for PKGBUILD HTTP fetches (override base connect timeout; cap total time).
21const PKGBUILD_CURL_EXTRA: &[&str] = &["--connect-timeout", "8", "--max-time", "10"];
22
23/// What: Fetch PKGBUILD content for a package from AUR or official Git packaging repos.
24///
25/// Inputs:
26/// - `item`: Package whose PKGBUILD should be retrieved.
27///
28/// Output:
29/// - `Ok(String)` with PKGBUILD text when available; `Err` on network or lookup failure.
30///
31/// # Errors
32/// - Returns `Err` when network request fails (curl execution error)
33/// - Returns `Err` when PKGBUILD cannot be fetched from AUR or official GitLab repositories
34/// - Returns `Err` when rate limiting mutex is poisoned
35/// - Returns `Err` when task spawn fails
36///
37/// # Panics
38/// - Panics if the rate limiting mutex is poisoned
39///
40/// Details:
41/// - First tries offline methods (yay/paru cache) for fast loading.
42/// - Then tries network with rate limiting and timeout (10s).
43/// - Uses curl with timeout to prevent hanging on slow servers.
44/// - Passes a short `--connect-timeout` after the base `curl_args` flags so it overrides the
45///   default 30s connect wait (important when official GitLab is unreachable).
46pub async fn fetch_pkgbuild_fast(item: &PackageItem) -> Result<String> {
47    let name = item.name.clone();
48
49    // 1. Try offline methods first (yay/paru cache) - this is fast!
50    if let Some(cached) = tokio::task::spawn_blocking({
51        let name = name.clone();
52        move || get_pkgbuild_from_cache(&name)
53    })
54    .await?
55    {
56        tracing::debug!("Using cached PKGBUILD for {} (offline)", name);
57        return Ok(cached);
58    }
59
60    // 2. Rate limiting: ensure minimum interval between requests
61    let delay = {
62        let mut last_request = PKGBUILD_RATE_LIMITER
63            .lock()
64            .expect("PKGBUILD rate limiter mutex poisoned");
65        if let Some(last) = *last_request {
66            let elapsed = last.elapsed();
67            if elapsed < Duration::from_millis(PKGBUILD_MIN_INTERVAL_MS) {
68                let delay = Duration::from_millis(PKGBUILD_MIN_INTERVAL_MS)
69                    .checked_sub(elapsed)
70                    .expect("elapsed should be less than PKGBUILD_MIN_INTERVAL_MS");
71                tracing::debug!(
72                    "Rate limiting PKGBUILD request for {}: waiting {:?}",
73                    name,
74                    delay
75                );
76                // Drop the guard before await
77                *last_request = Some(Instant::now());
78                Some(delay)
79            } else {
80                *last_request = Some(Instant::now());
81                None
82            }
83        } else {
84            *last_request = Some(Instant::now());
85            None
86        }
87    };
88    if let Some(delay) = delay {
89        tokio::time::sleep(delay).await;
90    }
91
92    // 3. Fetch from network with timeout
93    match &item.source {
94        Source::Aur => {
95            let url = format!(
96                "https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h={}",
97                percent_encode(&name)
98            );
99            // Use curl with timeout to prevent hanging
100            let res = tokio::task::spawn_blocking({
101                let url = url.clone();
102                move || crate::util::curl::curl_text_with_args(&url, PKGBUILD_CURL_EXTRA)
103            })
104            .await??;
105            Ok(res)
106        }
107        Source::Official { .. } => {
108            let url_main = format!(
109                "https://gitlab.archlinux.org/archlinux/packaging/packages/{}/-/raw/main/PKGBUILD",
110                percent_encode(&name)
111            );
112            let main_result = tokio::task::spawn_blocking({
113                let u = url_main.clone();
114                move || crate::util::curl::curl_text_with_args(&u, PKGBUILD_CURL_EXTRA)
115            })
116            .await;
117            if let Ok(Ok(txt)) = main_result {
118                return Ok(txt);
119            }
120            let url_master = format!(
121                "https://gitlab.archlinux.org/archlinux/packaging/packages/{}/-/raw/master/PKGBUILD",
122                percent_encode(&name)
123            );
124            let txt = tokio::task::spawn_blocking({
125                let u = url_master;
126                move || crate::util::curl::curl_text_with_args(&u, PKGBUILD_CURL_EXTRA)
127            })
128            .await??;
129            Ok(txt)
130        }
131    }
132}
133
134#[cfg(not(target_os = "windows"))]
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[tokio::test]
140    #[ignore = "Only run when explicitly mentioned"]
141    #[allow(clippy::await_holding_lock)]
142    async fn pkgbuild_fetches_aur_via_curl_text() {
143        let _guard = crate::sources::test_mutex()
144            .lock()
145            .expect("Test mutex poisoned");
146        // Shim PATH with fake curl
147        let old_path = std::env::var("PATH").unwrap_or_default();
148        let mut root = std::env::temp_dir();
149        root.push(format!(
150            "pacsea_fake_curl_pkgbuild_{}_{}",
151            std::process::id(),
152            std::time::SystemTime::now()
153                .duration_since(std::time::UNIX_EPOCH)
154                .expect("System time is before UNIX epoch")
155                .as_nanos()
156        ));
157        std::fs::create_dir_all(&root).expect("Failed to create test root directory");
158        let mut bin = root.clone();
159        bin.push("bin");
160        std::fs::create_dir_all(&bin).expect("Failed to create test bin directory");
161        let mut curl = bin.clone();
162        curl.push("curl");
163        let script = "#!/bin/sh\necho 'pkgver=1'\n";
164        std::fs::write(&curl, script.as_bytes()).expect("Failed to write test curl script");
165        #[cfg(unix)]
166        {
167            use std::os::unix::fs::PermissionsExt;
168            let mut perm = std::fs::metadata(&curl)
169                .expect("Failed to read test curl script metadata")
170                .permissions();
171            perm.set_mode(0o755);
172            std::fs::set_permissions(&curl, perm)
173                .expect("Failed to set test curl script permissions");
174        }
175        let new_path = format!("{}:{old_path}", bin.to_string_lossy());
176        unsafe { std::env::set_var("PATH", &new_path) };
177
178        let item = PackageItem {
179            name: "yay-bin".into(),
180            version: String::new(),
181            description: String::new(),
182            source: Source::Aur,
183            popularity: None,
184            out_of_date: None,
185            orphaned: false,
186        };
187        let txt = super::fetch_pkgbuild_fast(&item)
188            .await
189            .expect("Failed to fetch PKGBUILD in test");
190        assert!(txt.contains("pkgver=1"));
191
192        unsafe { std::env::set_var("PATH", &old_path) };
193        let _ = std::fs::remove_dir_all(&root);
194    }
195
196    #[test]
197    #[allow(clippy::await_holding_lock)]
198    fn pkgbuild_fetches_official_main_then_master() {
199        let _guard = crate::global_test_mutex_lock();
200        let old_path = std::env::var("PATH").unwrap_or_default();
201        let mut root = std::env::temp_dir();
202        root.push(format!(
203            "pacsea_fake_curl_pkgbuild_official_{}_{}",
204            std::process::id(),
205            std::time::SystemTime::now()
206                .duration_since(std::time::UNIX_EPOCH)
207                .expect("System time is before UNIX epoch")
208                .as_nanos()
209        ));
210        std::fs::create_dir_all(&root).expect("Failed to create test root directory");
211        let mut bin = root.clone();
212        bin.push("bin");
213        std::fs::create_dir_all(&bin).expect("Failed to create test bin directory");
214        let mut curl = bin.clone();
215        curl.push("curl");
216        // Fail when URL contains '/-/raw/main/' and succeed when '/-/raw/master/'
217        // curl_args creates defaults like:
218        // ["-sSLf", "--connect-timeout", "30", "--max-time", "90", "--max-filesize", "10485760", ...]
219        // Get the last argument by looping through all arguments
220        // Use printf instead of echo to avoid trailing newline that confuses the HTTP header parser
221        let script = "#!/bin/sh\nfor arg; do :; done\nurl=\"$arg\"\nif echo \"$url\" | grep -q '/-/raw/main/'; then exit 22; fi\nprintf 'pkgrel=2'\n";
222        std::fs::write(&curl, script.as_bytes()).expect("Failed to write test curl script");
223        #[cfg(unix)]
224        {
225            use std::os::unix::fs::PermissionsExt;
226            let mut perm = std::fs::metadata(&curl)
227                .expect("Failed to read test curl script metadata")
228                .permissions();
229            perm.set_mode(0o755);
230            std::fs::set_permissions(&curl, perm)
231                .expect("Failed to set test curl script permissions");
232        }
233
234        // Create fake paru and yay that fail (to prevent get_pkgbuild_from_cache from fetching real data)
235        let mut paru = bin.clone();
236        paru.push("paru");
237        std::fs::write(&paru, b"#!/bin/sh\nexit 1\n").expect("Failed to write test paru script");
238        #[cfg(unix)]
239        {
240            use std::os::unix::fs::PermissionsExt;
241            let mut perm = std::fs::metadata(&paru)
242                .expect("Failed to read test paru script metadata")
243                .permissions();
244            perm.set_mode(0o755);
245            std::fs::set_permissions(&paru, perm)
246                .expect("Failed to set test paru script permissions");
247        }
248
249        let mut yay = bin.clone();
250        yay.push("yay");
251        std::fs::write(&yay, b"#!/bin/sh\nexit 1\n").expect("Failed to write test yay script");
252        #[cfg(unix)]
253        {
254            use std::os::unix::fs::PermissionsExt;
255            let mut perm = std::fs::metadata(&yay)
256                .expect("Failed to read test yay script metadata")
257                .permissions();
258            perm.set_mode(0o755);
259            std::fs::set_permissions(&yay, perm)
260                .expect("Failed to set test yay script permissions");
261        }
262        let new_path = format!("{}:{old_path}", bin.to_string_lossy());
263        unsafe { std::env::set_var("PATH", &new_path) };
264        // Enable curl PATH lookup override so our fake curl is used instead of /usr/bin/curl
265        unsafe { std::env::set_var("PACSEA_CURL_PATH", "1") };
266
267        // Set HOME to empty directory to avoid finding cached PKGBUILDs
268        let old_home = std::env::var("HOME").unwrap_or_default();
269        unsafe { std::env::set_var("HOME", root.to_string_lossy().as_ref()) };
270
271        // Create a new tokio runtime AFTER setting PATH and HOME so worker threads inherit them
272        let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime for test");
273        let txt = rt.block_on(async {
274            let item = PackageItem {
275                name: "ripgrep".into(),
276                version: String::new(),
277                description: String::new(),
278                source: Source::Official {
279                    repo: "extra".into(),
280                    arch: "x86_64".into(),
281                },
282                popularity: None,
283                out_of_date: None,
284                orphaned: false,
285            };
286            super::fetch_pkgbuild_fast(&item)
287                .await
288                .expect("Failed to fetch PKGBUILD in test")
289        });
290
291        assert!(txt.contains("pkgrel=2"));
292
293        unsafe { std::env::set_var("PATH", &old_path) };
294        unsafe { std::env::set_var("HOME", &old_home) };
295        unsafe { std::env::remove_var("PACSEA_CURL_PATH") };
296        let _ = std::fs::remove_dir_all(&root);
297    }
298}