Skip to main content

pacsea/app/
sandbox_cache.rs

1//! Sandbox cache persistence for install list sandbox analysis.
2
3use super::cache_common::{self, CacheMatchMode};
4use crate::logic::sandbox::SandboxInfo;
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8/// What: Cache blob combining install list signature with resolved sandbox metadata.
9///
10/// Details:
11/// - `install_list_signature` mirrors package names used for cache validation.
12/// - `sandbox_info` preserves the last known sandbox analysis data for reuse.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SandboxCache {
15    /// Sorted list of package names from install list (used as signature).
16    pub install_list_signature: Vec<String>,
17    /// Cached resolved sandbox information.
18    pub sandbox_info: Vec<SandboxInfo>,
19}
20
21/// What: Generate a deterministic signature for sandbox cache comparisons.
22///
23/// Inputs:
24/// - `packages`: Slice of install list entries contributing their package names.
25///
26/// Output:
27/// - Sorted vector of package names that can be compared for cache validity checks.
28///
29/// Details:
30/// - Delegates to [`cache_common::compute_signature`], which sorts the cloned
31///   package names alphabetically to create an order-agnostic key.
32#[must_use]
33pub fn compute_signature(packages: &[crate::state::PackageItem]) -> Vec<String> {
34    cache_common::compute_signature(packages)
35}
36
37/// What: Load cached sandbox data when the stored signature matches the current list.
38///
39/// Inputs:
40/// - `path`: Filesystem location of the serialized `SandboxCache` JSON.
41/// - `current_signature`: Signature derived from the current install list for validation.
42///
43/// Output:
44/// - `Some(Vec<SandboxInfo>)` when the cache exists, deserializes, and signatures agree;
45///   `None` otherwise.
46///
47/// Details:
48/// - Reads the JSON, deserializes it, sorts both signatures, and compares them before
49///   returning the cached sandbox data.
50/// - Uses partial matching to load entries for packages that exist in both cache and current list.
51#[must_use]
52pub fn load_cache(path: &PathBuf, current_signature: &[String]) -> Option<Vec<SandboxInfo>> {
53    load_cache_partial(path, current_signature, false)
54}
55
56/// What: Load cached sandbox data with partial matching support.
57///
58/// Inputs:
59/// - `path`: Filesystem location of the serialized `SandboxCache` JSON.
60/// - `current_signature`: Signature derived from the current install list for validation.
61/// - `exact_match_only`: If true, only match when signatures are identical. If false, allow partial matching.
62///
63/// Output:
64/// - `Some(Vec<SandboxInfo>)` when the cache exists and matches (exact or partial);
65///   `None` otherwise.
66///
67/// Details:
68/// - If `exact_match_only` is false, loads entries for packages that exist in both
69///   the cached signature and the current signature (intersection matching).
70/// - This allows preserving sandbox data when packages are added to the install list.
71// `&PathBuf` is kept (rather than `&Path`) to preserve the historical public
72// signature used by `load_cache`, which `runtime::init` consumes as a closure.
73#[allow(clippy::ptr_arg)]
74#[must_use]
75pub fn load_cache_partial(
76    path: &PathBuf,
77    current_signature: &[String],
78    exact_match_only: bool,
79) -> Option<Vec<SandboxInfo>> {
80    let SandboxCache {
81        install_list_signature,
82        sandbox_info,
83    } = cache_common::load_signed_cache(path, "Sandbox", "sandbox")?;
84    let mode = if exact_match_only {
85        CacheMatchMode::Exact
86    } else {
87        CacheMatchMode::Intersection
88    };
89    cache_common::take_signature_match(
90        mode,
91        path,
92        "sandbox",
93        current_signature,
94        &install_list_signature,
95        sandbox_info,
96        |info| info.package_name.as_str(),
97    )
98}
99
100/// What: Persist sandbox cache payload and signature to disk as JSON.
101///
102/// Inputs:
103/// - `path`: Destination file for the serialized cache contents.
104/// - `signature`: Current install list signature to store alongside the payload.
105/// - `sandbox_info`: Sandbox analysis metadata being cached.
106///
107/// Output:
108/// - No return value; writes to disk best-effort and logs a debug message when successful.
109///
110/// Details:
111/// - Serializes the data to JSON, writes it to `path`, and includes the record count in logs.
112// `&PathBuf` is kept (rather than `&Path`) to preserve the historical public
113// signature shared by all install-list cache modules and their callers.
114#[allow(clippy::ptr_arg)]
115pub fn save_cache(path: &PathBuf, signature: &[String], sandbox_info: &[SandboxInfo]) {
116    let cache = SandboxCache {
117        install_list_signature: signature.to_vec(),
118        sandbox_info: sandbox_info.to_vec(),
119    };
120    cache_common::save_signed_cache(path, &cache, sandbox_info.len(), "sandbox");
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::logic::sandbox::{DependencyDelta, SandboxInfo};
127    use crate::state::{PackageItem, Source};
128    use std::fs;
129    use std::time::{SystemTime, UNIX_EPOCH};
130
131    fn temp_path(label: &str) -> std::path::PathBuf {
132        let mut path = std::env::temp_dir();
133        path.push(format!(
134            "pacsea_sandbox_cache_{label}_{}_{}.json",
135            std::process::id(),
136            SystemTime::now()
137                .duration_since(UNIX_EPOCH)
138                .expect("System time is before UNIX epoch")
139                .as_nanos()
140        ));
141        path
142    }
143
144    fn sample_packages() -> Vec<PackageItem> {
145        vec![PackageItem {
146            name: "yay".into(),
147            version: "12.0.0".into(),
148            description: String::new(),
149            source: Source::Aur,
150            popularity: None,
151            out_of_date: None,
152            orphaned: false,
153        }]
154    }
155
156    fn sample_sandbox_info() -> Vec<SandboxInfo> {
157        vec![SandboxInfo {
158            package_name: "yay".into(),
159            depends: vec![DependencyDelta {
160                name: "go".into(),
161                is_installed: true,
162                installed_version: Some("1.21.0".into()),
163                version_satisfied: true,
164            }],
165            makedepends: vec![],
166            checkdepends: vec![],
167            optdepends: vec![],
168        }]
169    }
170
171    #[test]
172    /// What: Ensure `compute_signature` normalizes package name ordering.
173    /// Inputs:
174    /// - Install list cloned from the sample data but iterated in reverse.
175    ///
176    /// Output:
177    /// - Signature equals `["yay"]`.
178    fn compute_signature_orders_package_names() {
179        let mut packages = sample_packages();
180        packages.reverse();
181        let signature = compute_signature(&packages);
182        assert_eq!(signature, vec![String::from("yay")]);
183    }
184
185    #[test]
186    /// What: Confirm `load_cache` rejects persisted caches whose signature does not match.
187    /// Inputs:
188    /// - Cache saved for `["yay"]` but reloaded with signature `["paru"]`.
189    ///
190    /// Output:
191    /// - `None`.
192    fn load_cache_rejects_signature_mismatch() {
193        let path = temp_path("mismatch");
194        let packages = sample_packages();
195        let signature = compute_signature(&packages);
196        let sandbox_info = sample_sandbox_info();
197        save_cache(&path, &signature, &sandbox_info);
198
199        let mismatched_signature = vec!["paru".into()];
200        assert!(load_cache(&path, &mismatched_signature).is_none());
201        let _ = fs::remove_file(&path);
202    }
203
204    #[test]
205    /// What: Verify cached sandbox metadata survives a save/load round trip.
206    /// Inputs:
207    /// - Sample `yay` sandbox info written to disk and reloaded with matching signature.
208    ///
209    /// Output:
210    /// - Reloaded metadata matches the original package name and properties.
211    fn save_and_load_cache_roundtrip() {
212        let path = temp_path("roundtrip");
213        let packages = sample_packages();
214        let signature = compute_signature(&packages);
215        let sandbox_info = sample_sandbox_info();
216        save_cache(&path, &signature, &sandbox_info);
217
218        let reloaded = load_cache(&path, &signature).expect("expected cache to load");
219        assert_eq!(reloaded.len(), sandbox_info.len());
220        assert_eq!(reloaded[0].package_name, sandbox_info[0].package_name);
221        assert_eq!(reloaded[0].depends.len(), sandbox_info[0].depends.len());
222
223        let _ = fs::remove_file(&path);
224    }
225
226    #[test]
227    /// What: Verify partial cache loading preserves entries when new packages are added.
228    /// Inputs:
229    /// - Cache saved for `["jujutsu-git"]` but reloaded with signature `["jujutsu-git", "pacsea-bin"]`.
230    ///
231    /// Output:
232    /// - Returns `Some(Vec<SandboxInfo>)` containing only `jujutsu-git` entry (partial match).
233    fn load_cache_partial_match() {
234        let path = temp_path("partial");
235        let jujutsu_sandbox = SandboxInfo {
236            package_name: "jujutsu-git".into(),
237            depends: vec![DependencyDelta {
238                name: "python".into(),
239                is_installed: true,
240                installed_version: Some("3.11.0".into()),
241                version_satisfied: true,
242            }],
243            makedepends: vec![],
244            checkdepends: vec![],
245            optdepends: vec![],
246        };
247        let signature = vec!["jujutsu-git".into()];
248        save_cache(&path, &signature, std::slice::from_ref(&jujutsu_sandbox));
249
250        // Try to load with expanded signature (new package added)
251        let expanded_signature = vec!["jujutsu-git".into(), "pacsea-bin".into()];
252        let reloaded =
253            load_cache(&path, &expanded_signature).expect("expected partial cache to load");
254
255        assert_eq!(reloaded.len(), 1);
256        assert_eq!(reloaded[0].package_name, "jujutsu-git");
257        assert_eq!(reloaded[0].depends.len(), 1);
258        assert_eq!(reloaded[0].depends[0].name, "python");
259
260        let _ = fs::remove_file(&path);
261    }
262
263    #[test]
264    /// What: Verify partial cache loading returns None when no packages overlap.
265    /// Inputs:
266    /// - Cache saved for `["jujutsu-git"]` but reloaded with signature `["pacsea-bin"]`.
267    ///
268    /// Output:
269    /// - Returns `None` (no overlap).
270    fn load_cache_partial_no_overlap() {
271        let path = temp_path("no_overlap");
272        let jujutsu_sandbox = sample_sandbox_info();
273        let signature = vec!["jujutsu-git".into()];
274        save_cache(&path, &signature, &jujutsu_sandbox);
275
276        let different_signature = vec!["pacsea-bin".into()];
277        assert!(load_cache(&path, &different_signature).is_none());
278
279        let _ = fs::remove_file(&path);
280    }
281}