pacsea/logic/files/
pkgbuild_fetch.rs

1//! PKGBUILD fetching functions.
2
3use crate::util::{curl_args, percent_encode};
4use std::process::Command;
5use std::sync::Mutex;
6use std::time::{Duration, Instant};
7
8/// Rate limiter for PKGBUILD requests to avoid overwhelming AUR servers.
9///
10/// Tracks the timestamp of the last PKGBUILD request to enforce minimum intervals.
11static PKGBUILD_RATE_LIMITER: Mutex<Option<Instant>> = Mutex::new(None);
12/// Minimum interval between PKGBUILD requests in milliseconds.
13///
14/// Prevents overwhelming AUR servers with too many rapid requests.
15const PKGBUILD_MIN_INTERVAL_MS: u64 = 500;
16
17/// What: Try to find PKGBUILD in a directory structure.
18///
19/// Inputs:
20/// - `base_dir`: Base directory to search in.
21/// - `name`: Package name for logging.
22/// - `helper_name`: Helper name for logging (e.g., "paru" or "yay").
23///
24/// Output:
25/// - Returns PKGBUILD content if found, or None.
26///
27/// Details:
28/// - First checks `base_dir`/`PKGBUILD`, then searches subdirectories.
29fn find_pkgbuild_in_dir(
30    base_dir: &std::path::Path,
31    name: &str,
32    helper_name: &str,
33) -> Option<String> {
34    // Try direct path first
35    let pkgbuild_path = base_dir.join("PKGBUILD");
36    if let Ok(text) = std::fs::read_to_string(&pkgbuild_path)
37        && text.contains("pkgname")
38    {
39        tracing::debug!("Found PKGBUILD for {} via {} -G", name, helper_name);
40        return Some(text);
41    }
42
43    // Search in subdirectories
44    let Ok(entries) = std::fs::read_dir(base_dir) else {
45        return None;
46    };
47
48    for entry in entries.flatten() {
49        if !entry.path().is_dir() {
50            continue;
51        }
52
53        let pkgbuild_path = entry.path().join("PKGBUILD");
54        if let Ok(text) = std::fs::read_to_string(&pkgbuild_path)
55            && text.contains("pkgname")
56        {
57            tracing::debug!(
58                "Found PKGBUILD for {} via {} -G (in subdir)",
59                name,
60                helper_name
61            );
62            return Some(text);
63        }
64    }
65
66    None
67}
68
69/// What: Try to get PKGBUILD using a helper command (paru -G or yay -G).
70///
71/// Inputs:
72/// - `helper`: Helper command name ("paru" or "yay").
73/// - `name`: Package name.
74///
75/// Output:
76/// - Returns PKGBUILD content if found, or None.
77///
78/// Details:
79/// - Executes helper -G command in a temp directory and searches for PKGBUILD.
80fn try_helper_command(helper: &str, name: &str) -> Option<String> {
81    let temp_dir = std::env::temp_dir().join(format!("pacsea_pkgbuild_{name}"));
82    let _ = std::fs::create_dir_all(&temp_dir);
83
84    let output = Command::new(helper)
85        .args(["-G", name])
86        .current_dir(&temp_dir)
87        .output()
88        .ok()?;
89
90    if !output.status.success() {
91        let _ = std::fs::remove_dir_all(&temp_dir);
92        return None;
93    }
94
95    let result = find_pkgbuild_in_dir(&temp_dir.join(name), name, helper);
96    let _ = std::fs::remove_dir_all(&temp_dir);
97    result
98}
99
100/// What: Try to read PKGBUILD directly from known cache paths.
101///
102/// Inputs:
103/// - `name`: Package name.
104/// - `home`: Home directory path.
105///
106/// Output:
107/// - Returns PKGBUILD content if found, or None.
108///
109/// Details:
110/// - Checks standard cache locations for paru and yay.
111fn try_direct_cache_paths(name: &str, home: &str) -> Option<String> {
112    let cache_paths = [
113        format!("{home}/.cache/paru/clone/{name}/PKGBUILD"),
114        format!("{home}/.cache/yay/{name}/PKGBUILD"),
115    ];
116
117    for path_str in cache_paths {
118        if let Ok(text) = std::fs::read_to_string(&path_str)
119            && text.contains("pkgname")
120        {
121            tracing::debug!("Found PKGBUILD for {} in cache: {}", name, path_str);
122            return Some(text);
123        }
124    }
125
126    None
127}
128
129/// What: Try to find PKGBUILD in cache subdirectories.
130///
131/// Inputs:
132/// - `name`: Package name.
133/// - `home`: Home directory path.
134///
135/// Output:
136/// - Returns PKGBUILD content if found, or None.
137///
138/// Details:
139/// - Searches cache directories for packages that might be in subdirectories.
140fn try_cache_subdirectories(name: &str, home: &str) -> Option<String> {
141    let cache_bases = [
142        format!("{home}/.cache/paru/clone"),
143        format!("{home}/.cache/yay"),
144    ];
145
146    for cache_base in cache_bases {
147        let Ok(entries) = std::fs::read_dir(&cache_base) else {
148            continue;
149        };
150
151        for entry in entries.flatten() {
152            let path = entry.path();
153            if !path.is_dir() {
154                continue;
155            }
156
157            let matches_name = path
158                .file_name()
159                .and_then(|n| n.to_str())
160                .is_some_and(|n| n.contains(name));
161
162            if !matches_name {
163                continue;
164            }
165
166            // Check direct PKGBUILD
167            let pkgbuild_path = path.join("PKGBUILD");
168            if let Ok(text) = std::fs::read_to_string(&pkgbuild_path)
169                && text.contains("pkgname")
170            {
171                tracing::debug!(
172                    "Found PKGBUILD for {} in cache subdirectory: {:?}",
173                    name,
174                    pkgbuild_path
175                );
176                return Some(text);
177            }
178
179            // Check subdirectories
180            let Ok(sub_entries) = std::fs::read_dir(&path) else {
181                continue;
182            };
183
184            for sub_entry in sub_entries.flatten() {
185                if !sub_entry.path().is_dir() {
186                    continue;
187                }
188
189                let pkgbuild_path = sub_entry.path().join("PKGBUILD");
190                if let Ok(text) = std::fs::read_to_string(&pkgbuild_path)
191                    && text.contains("pkgname")
192                {
193                    tracing::debug!(
194                        "Found PKGBUILD for {} in cache subdirectory: {:?}",
195                        name,
196                        pkgbuild_path
197                    );
198                    return Some(text);
199                }
200            }
201        }
202    }
203
204    None
205}
206
207/// What: Get PKGBUILD from yay/paru cache (offline method).
208///
209/// Inputs:
210/// - `name`: Package name.
211///
212/// Output:
213/// - Returns PKGBUILD content if found in cache, or None.
214///
215/// Details:
216/// - Checks yay cache (~/.cache/yay) and paru cache (~/.cache/paru).
217/// - Also tries using `yay -G` or `paru -G` commands.
218#[must_use]
219pub fn get_pkgbuild_from_cache(name: &str) -> Option<String> {
220    // Try helper commands first (fastest, uses helper's cache)
221    if let Some(text) = try_helper_command("paru", name) {
222        return Some(text);
223    }
224    if let Some(text) = try_helper_command("yay", name) {
225        return Some(text);
226    }
227
228    // Try reading directly from cache directories
229    let home = std::env::var("HOME").ok()?;
230    if let Some(text) = try_direct_cache_paths(name, &home) {
231        return Some(text);
232    }
233
234    // Try finding PKGBUILD in cache subdirectories
235    try_cache_subdirectories(name, &home)
236}
237
238/// What: Fetch PKGBUILD content synchronously (blocking).
239///
240/// Inputs:
241/// - `name`: Package name.
242///
243/// Output:
244/// - Returns PKGBUILD content as a string, or an error if fetch fails.
245///
246/// # Errors
247/// - Returns `Err` when network request fails (curl execution error)
248/// - Returns `Err` when PKGBUILD cannot be fetched from AUR or official repositories
249/// - Returns `Err` when rate limiting mutex is poisoned
250///
251/// # Panics
252/// - Panics if the rate limiting mutex is poisoned
253///
254/// Details:
255/// - First tries offline methods (yay/paru cache, yay -G, paru -G).
256/// - Then tries AUR with rate limiting (500ms between requests).
257/// - Falls back to official GitLab repos for official packages.
258/// - Uses curl to fetch PKGBUILD from AUR or official GitLab repos.
259pub fn fetch_pkgbuild_sync(name: &str) -> Result<String, String> {
260    // 1. Try offline methods first (yay/paru cache)
261    if let Some(cached) = get_pkgbuild_from_cache(name) {
262        tracing::debug!("Using cached PKGBUILD for {} (offline)", name);
263        return Ok(cached);
264    }
265
266    // 2. Rate limiting: ensure minimum interval between requests
267    {
268        let mut last_request = PKGBUILD_RATE_LIMITER
269            .lock()
270            .expect("PKGBUILD rate limiter mutex poisoned");
271        if let Some(last) = *last_request {
272            let elapsed = last.elapsed();
273            if elapsed < Duration::from_millis(PKGBUILD_MIN_INTERVAL_MS) {
274                let delay = Duration::from_millis(PKGBUILD_MIN_INTERVAL_MS)
275                    .checked_sub(elapsed)
276                    .expect("elapsed should be less than PKGBUILD_MIN_INTERVAL_MS");
277                tracing::debug!(
278                    "Rate limiting PKGBUILD request for {}: waiting {:?}",
279                    name,
280                    delay
281                );
282                std::thread::sleep(delay);
283            }
284        }
285        *last_request = Some(Instant::now());
286    }
287
288    // 3. Try AUR first (works for both AUR and official packages via AUR mirror)
289    let url_aur = format!(
290        "https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h={}",
291        percent_encode(name)
292    );
293    tracing::debug!("Fetching PKGBUILD from AUR: {}", url_aur);
294
295    let args = curl_args(&url_aur, &[]);
296    let output = Command::new("curl").args(&args).output();
297
298    let aur_failed_http_error = match &output {
299        Ok(output) if output.status.success() => {
300            let text = String::from_utf8_lossy(&output.stdout).to_string();
301            if !text.trim().is_empty() && text.contains("pkgname") {
302                return Ok(text);
303            }
304            false
305        }
306        Ok(output) => {
307            // curl with -f flag returns exit code 22 for HTTP errors like 502
308            // If AUR returns 502 (Bad Gateway), don't try GitLab fallback
309            // GitLab should only be used for official packages, not AUR packages
310            // AUR 502 indicates a temporary AUR server issue, not that the package doesn't exist in AUR
311            output.status.code().is_some_and(|code| code == 22)
312        }
313        _ => false,
314    };
315
316    if aur_failed_http_error {
317        tracing::debug!(
318            "AUR returned HTTP error (likely 502) for {} - skipping GitLab fallback (likely AUR package or temporary AUR issue)",
319            name
320        );
321        return Err("AUR returned HTTP error (likely 502 Bad Gateway)".to_string());
322    }
323
324    // Fallback to official GitLab repos (only for official packages, not AUR)
325    let url_main = format!(
326        "https://gitlab.archlinux.org/archlinux/packaging/packages/{}/-/raw/main/PKGBUILD",
327        percent_encode(name)
328    );
329    tracing::debug!("Fetching PKGBUILD from GitLab main: {}", url_main);
330
331    let args = curl_args(&url_main, &[]);
332    let output = Command::new("curl").args(&args).output();
333
334    match output {
335        Ok(output) if output.status.success() => {
336            let text = String::from_utf8_lossy(&output.stdout).to_string();
337            // Validate that we got a PKGBUILD, not HTML (e.g., login page)
338            if !text.trim().is_empty()
339                && (text.contains("pkgname") || text.contains("pkgver") || text.contains("pkgdesc"))
340                && !text.trim_start().starts_with("<!DOCTYPE")
341                && !text.trim_start().starts_with("<html")
342            {
343                return Ok(text);
344            }
345            tracing::warn!(
346                "GitLab main returned invalid PKGBUILD (likely HTML): first 200 chars: {:?}",
347                text.chars().take(200).collect::<String>()
348            );
349        }
350        _ => {}
351    }
352
353    // Try master branch as fallback
354    let url_master = format!(
355        "https://gitlab.archlinux.org/archlinux/packaging/packages/{}/-/raw/master/PKGBUILD",
356        percent_encode(name)
357    );
358    tracing::debug!("Fetching PKGBUILD from GitLab master: {}", url_master);
359
360    let args = curl_args(&url_master, &[]);
361    let output = Command::new("curl")
362        .args(&args)
363        .output()
364        .map_err(|e| format!("curl failed: {e}"))?;
365
366    if !output.status.success() {
367        return Err(format!(
368            "curl failed with status: {:?}",
369            output.status.code()
370        ));
371    }
372
373    let text = String::from_utf8_lossy(&output.stdout).to_string();
374    if text.trim().is_empty() {
375        return Err("Empty PKGBUILD content".to_string());
376    }
377
378    // Validate that we got a PKGBUILD, not HTML (e.g., login page)
379    if text.trim_start().starts_with("<!DOCTYPE") || text.trim_start().starts_with("<html") {
380        tracing::warn!(
381            "GitLab master returned HTML instead of PKGBUILD: first 200 chars: {:?}",
382            text.chars().take(200).collect::<String>()
383        );
384        return Err("GitLab returned HTML page instead of PKGBUILD".to_string());
385    }
386
387    if !text.contains("pkgname") && !text.contains("pkgver") && !text.contains("pkgdesc") {
388        tracing::warn!(
389            "GitLab master returned content that doesn't look like PKGBUILD: first 200 chars: {:?}",
390            text.chars().take(200).collect::<String>()
391        );
392        return Err("Response doesn't appear to be a valid PKGBUILD".to_string());
393    }
394
395    Ok(text)
396}
397
398/// What: Fetch .SRCINFO content synchronously (blocking).
399///
400/// Inputs:
401/// - `name`: AUR package name.
402///
403/// Output:
404/// - Returns .SRCINFO content as a string, or an error if fetch fails.
405///
406/// # Errors
407/// - Returns `Err` when network request fails (curl execution error)
408/// - Returns `Err` when .SRCINFO cannot be fetched from AUR
409///
410/// Details:
411/// - Downloads .SRCINFO from AUR cgit repository.
412pub fn fetch_srcinfo_sync(name: &str) -> Result<String, String> {
413    crate::util::srcinfo::fetch_srcinfo(name, None)
414}