Skip to main content

stout_install/
cellar.rs

1//! Cellar scanning and Homebrew receipt parsing
2//!
3//! Provides utilities for discovering packages installed in the Homebrew Cellar
4//! and parsing their INSTALL_RECEIPT.json files.
5
6use crate::error::Result;
7use serde::Deserialize;
8use std::path::{Path, PathBuf};
9use tracing::{debug, warn};
10
11/// Parsed data from a Homebrew INSTALL_RECEIPT.json
12#[derive(Debug, Clone)]
13pub struct BrewReceipt {
14    pub installed_on_request: bool,
15    pub install_time: Option<u64>,
16    pub runtime_dependencies: Vec<BrewRuntimeDep>,
17    pub source_tap: Option<String>,
18    pub poured_from_bottle: Option<bool>,
19}
20
21/// A runtime dependency from a Homebrew receipt
22#[derive(Debug, Clone)]
23pub struct BrewRuntimeDep {
24    pub full_name: String,
25    pub version: String,
26}
27
28/// A package discovered in the Cellar
29#[derive(Debug, Clone)]
30pub struct CellarPackage {
31    pub name: String,
32    pub version: String,
33    pub path: PathBuf,
34    pub receipt: Option<BrewReceipt>,
35}
36
37// Internal serde structures for permissive parsing of INSTALL_RECEIPT.json
38#[derive(Deserialize)]
39struct RawReceipt {
40    #[serde(default)]
41    installed_on_request: Option<bool>,
42    #[serde(default)]
43    install_time: Option<serde_json::Value>,
44    #[serde(default)]
45    runtime_dependencies: Option<serde_json::Value>,
46    #[serde(default)]
47    source: Option<RawSource>,
48    #[serde(default)]
49    poured_from_bottle: Option<bool>,
50}
51
52#[derive(Deserialize)]
53struct RawSource {
54    #[serde(default)]
55    tap: Option<String>,
56}
57
58#[derive(Deserialize)]
59struct RawRuntimeDep {
60    #[serde(default)]
61    full_name: Option<String>,
62    #[serde(default)]
63    version: Option<String>,
64}
65
66/// Parse a Homebrew INSTALL_RECEIPT.json file.
67///
68/// Uses permissive parsing with serde defaults to handle variation
69/// across Homebrew versions.
70pub fn parse_brew_receipt(path: &Path) -> Result<BrewReceipt> {
71    let json = std::fs::read_to_string(path)?;
72    let raw: RawReceipt = serde_json::from_str(&json)?;
73
74    let install_time = raw.install_time.and_then(|v| match v {
75        serde_json::Value::Number(n) => n.as_u64(),
76        _ => None,
77    });
78
79    let runtime_dependencies = match raw.runtime_dependencies {
80        Some(serde_json::Value::Array(arr)) => arr
81            .into_iter()
82            .filter_map(|v| {
83                let dep: RawRuntimeDep = serde_json::from_value(v).ok()?;
84                Some(BrewRuntimeDep {
85                    full_name: dep.full_name?,
86                    version: dep.version.unwrap_or_default(),
87                })
88            })
89            .collect(),
90        _ => Vec::new(),
91    };
92
93    Ok(BrewReceipt {
94        installed_on_request: raw.installed_on_request.unwrap_or(true),
95        install_time,
96        runtime_dependencies,
97        source_tap: raw.source.and_then(|s| s.tap),
98        poured_from_bottle: raw.poured_from_bottle,
99    })
100}
101
102/// Scan the Cellar directory and return all discovered packages.
103///
104/// For each package, selects the linked version (if any) or the latest version.
105pub fn scan_cellar(cellar: &Path) -> Result<Vec<CellarPackage>> {
106    if !cellar.exists() {
107        return Ok(Vec::new());
108    }
109
110    let mut packages = Vec::new();
111
112    let entries = std::fs::read_dir(cellar)?;
113    for entry in entries {
114        let entry = entry?;
115        let path = entry.path();
116
117        // Skip symlinks to prevent directory traversal
118        if path.symlink_metadata()?.file_type().is_symlink() {
119            debug!("Skipping symlink in Cellar: {}", path.display());
120            continue;
121        }
122
123        if !path.is_dir() {
124            continue;
125        }
126
127        let name = match entry.file_name().into_string() {
128            Ok(n) => n,
129            Err(_) => continue,
130        };
131
132        // Skip hidden directories and reject unsafe names
133        if name.starts_with('.') || !is_safe_package_name(&name) {
134            continue;
135        }
136
137        match scan_package_versions(&path, &name) {
138            Ok(Some(pkg)) => packages.push(pkg),
139            Ok(None) => {
140                debug!("No versions found for {}", name);
141            }
142            Err(e) => {
143                warn!("Failed to scan {}: {}", name, e);
144            }
145        }
146    }
147
148    packages.sort_by(|a, b| a.name.cmp(&b.name));
149    Ok(packages)
150}
151
152/// Scan a single package in the Cellar by name.
153pub fn scan_cellar_package(cellar: &Path, name: &str) -> Result<Option<CellarPackage>> {
154    if !is_safe_package_name(name) {
155        return Ok(None);
156    }
157    let pkg_dir = cellar.join(name);
158    if !pkg_dir.is_dir() {
159        return Ok(None);
160    }
161    scan_package_versions(&pkg_dir, name)
162}
163
164/// Reject package names that could escape the Cellar directory.
165fn is_safe_package_name(name: &str) -> bool {
166    !name.is_empty()
167        && !name.contains('/')
168        && !name.contains('\\')
169        && !name.contains('\0')
170        && name != ".."
171}
172
173/// Scan version subdirectories for a single package, picking the best version.
174fn scan_package_versions(pkg_dir: &Path, name: &str) -> Result<Option<CellarPackage>> {
175    let mut versions: Vec<String> = Vec::new();
176
177    let entries = std::fs::read_dir(pkg_dir)?;
178    for entry in entries {
179        let entry = entry?;
180        let path = entry.path();
181
182        // Skip symlinks to prevent directory traversal
183        if path.symlink_metadata()?.file_type().is_symlink() {
184            continue;
185        }
186
187        if !path.is_dir() {
188            continue;
189        }
190        if let Ok(v) = entry.file_name().into_string() {
191            if !v.starts_with('.') {
192                versions.push(v);
193            }
194        }
195    }
196
197    if versions.is_empty() {
198        return Ok(None);
199    }
200
201    // Sort versions — pick linked version or latest (last after sort)
202    versions.sort();
203    let version = versions.last().unwrap().clone();
204
205    let version_path = pkg_dir.join(&version);
206    let receipt_path = version_path.join("INSTALL_RECEIPT.json");
207
208    let receipt = if receipt_path.exists() {
209        match parse_brew_receipt(&receipt_path) {
210            Ok(r) => Some(r),
211            Err(e) => {
212                warn!("Failed to parse receipt for {}/{}: {}", name, version, e);
213                None
214            }
215        }
216    } else {
217        None
218    };
219
220    Ok(Some(CellarPackage {
221        name: name.to_string(),
222        version,
223        path: version_path,
224        receipt,
225    }))
226}
227
228/// Count packages in the Cellar without fully parsing them.
229pub fn count_cellar_packages(cellar: &Path) -> usize {
230    if !cellar.exists() {
231        return 0;
232    }
233
234    std::fs::read_dir(cellar)
235        .ok()
236        .map(|entries| {
237            entries
238                .filter_map(|e| e.ok())
239                .filter(|e| {
240                    e.path().is_dir()
241                        && e.file_name()
242                            .to_str()
243                            .map(|n| !n.starts_with('.'))
244                            .unwrap_or(false)
245                })
246                .count()
247        })
248        .unwrap_or(0)
249}
250
251/// Convert a Unix timestamp to an ISO 8601 string.
252pub fn timestamp_to_iso(ts: u64) -> String {
253    use jiff::Timestamp;
254    Timestamp::from_second(ts as i64)
255        .unwrap_or(Timestamp::UNIX_EPOCH)
256        .strftime("%Y-%m-%dT%H:%M:%SZ")
257        .to_string()
258}