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}