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