1use std::fmt::Write as _;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9use sha2::{Digest, Sha256};
10
11use crate::cache::{CacheConfig, write_bytes_atomically};
12use crate::license_detection::index::LicenseIndex;
13use crate::license_detection::models::{LoadedLicense, LoadedRule};
14
15const CACHE_ROOT_DIR_NAME: &str = "license-index";
16const CACHE_FILE_EXTENSION: &str = "rkyv";
17
18const FINGERPRINT_LEN: usize = 32;
26const PAYLOAD_DIGEST_LEN: usize = 32;
27const HEADER_LEN: usize = FINGERPRINT_LEN + PAYLOAD_DIGEST_LEN;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum LicenseCacheNamespace {
31 Embedded,
32 CustomRules,
33}
34
35impl LicenseCacheNamespace {
36 fn directory_name(self) -> &'static str {
37 match self {
38 Self::Embedded => "embedded",
39 Self::CustomRules => "custom",
40 }
41 }
42}
43
44pub struct LicenseCacheConfig {
45 pub root_dir: PathBuf,
46 pub reindex: bool,
47 pub enabled: bool,
48}
49
50impl LicenseCacheConfig {
51 pub fn new(root_dir: PathBuf, reindex: bool, enabled: bool) -> Self {
52 Self {
53 root_dir,
54 reindex,
55 enabled,
56 }
57 }
58
59 pub fn default_root_dir() -> PathBuf {
60 CacheConfig::default_root_dir_without_scan_root()
61 }
62
63 fn namespace_dir(&self, namespace: LicenseCacheNamespace) -> PathBuf {
64 self.root_dir
65 .join(CACHE_ROOT_DIR_NAME)
66 .join(namespace.directory_name())
67 }
68
69 fn cache_file_path(&self, namespace: LicenseCacheNamespace, fingerprint: &[u8; 32]) -> PathBuf {
70 self.namespace_dir(namespace).join(format!(
71 "{}.{}",
72 fingerprint_hex(fingerprint),
73 CACHE_FILE_EXTENSION
74 ))
75 }
76}
77
78fn fingerprint_hex(fingerprint: &[u8; 32]) -> String {
79 let mut hex = String::with_capacity(fingerprint.len() * 2);
80 for byte in fingerprint {
81 let _ = write!(&mut hex, "{byte:02x}");
82 }
83 hex
84}
85
86fn prune_namespace_dir(namespace_dir: &Path, active_path: &Path) -> Result<()> {
87 if !namespace_dir.exists() {
88 return Ok(());
89 }
90
91 for entry in fs::read_dir(namespace_dir)
92 .with_context(|| format!("Failed to read license cache namespace {namespace_dir:?}"))?
93 {
94 let path = entry?.path();
95 if path == active_path
96 || path.extension().and_then(|ext| ext.to_str()) != Some(CACHE_FILE_EXTENSION)
97 {
98 continue;
99 }
100 fs::remove_file(&path)
101 .with_context(|| format!("Failed to prune stale license cache file {path:?}"))?;
102 }
103
104 Ok(())
105}
106
107pub fn compute_rules_fingerprint(
108 rules: &[LoadedRule],
109 licenses: &[LoadedLicense],
110) -> Result<[u8; 32]> {
111 let mut sorted_rules: Vec<_> = rules.iter().collect();
112 sorted_rules.sort_by_key(|r| &r.identifier);
113 let mut sorted_licenses: Vec<_> = licenses.iter().collect();
114 sorted_licenses.sort_by_key(|l| &l.key);
115
116 let serialized = postcard::to_allocvec(&(sorted_rules, sorted_licenses))
117 .context("Failed to serialize effective rules/licenses for cache fingerprinting")?;
118
119 Ok(Sha256::digest(serialized).into())
120}
121
122pub fn compute_artifact_fingerprint(artifact_bytes: &[u8]) -> [u8; 32] {
123 Sha256::digest(artifact_bytes).into()
124}
125
126pub fn load_cached_index(
127 config: &LicenseCacheConfig,
128 namespace: LicenseCacheNamespace,
129 fingerprint: &[u8; 32],
130) -> Result<Option<LicenseIndex>> {
131 if !config.enabled {
132 return Ok(None);
133 }
134
135 let cache_path = config.cache_file_path(namespace, fingerprint);
136
137 if !cache_path.exists() {
138 return Ok(None);
139 }
140
141 let bytes = match fs::read(&cache_path) {
142 Ok(bytes) => bytes,
143 Err(_) => return Ok(None),
144 };
145
146 if bytes.len() < HEADER_LEN {
150 return Ok(None);
151 }
152
153 let stored_fingerprint = &bytes[..FINGERPRINT_LEN];
154 if stored_fingerprint != fingerprint.as_slice() {
155 return Ok(None);
156 }
157
158 let stored_digest = &bytes[FINGERPRINT_LEN..HEADER_LEN];
159 let payload = &bytes[HEADER_LEN..];
160
161 let computed_digest: [u8; PAYLOAD_DIGEST_LEN] = Sha256::digest(payload).into();
165 if stored_digest != computed_digest.as_slice() {
166 return Ok(None);
167 }
168
169 let archived = match rkyv::access::<rkyv::Archived<LicenseIndex>, rkyv::rancor::Error>(payload)
170 {
171 Ok(archived) => archived,
172 Err(_) => return Ok(None),
173 };
174
175 match rkyv::deserialize::<LicenseIndex, rkyv::rancor::Error>(archived) {
178 Ok(cached) => Ok(Some(cached)),
179 Err(_) => Ok(None),
180 }
181}
182
183pub fn save_cached_index(
184 config: &LicenseCacheConfig,
185 namespace: LicenseCacheNamespace,
186 cached: &LicenseIndex,
187 fingerprint: &[u8; 32],
188) -> Result<()> {
189 if !config.enabled {
190 return Ok(());
191 }
192
193 let rkyv_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cached)
194 .map_err(|e| anyhow::anyhow!("Failed to serialize license index cache: {}", e))?;
195
196 let payload_digest: [u8; PAYLOAD_DIGEST_LEN] = Sha256::digest(&rkyv_bytes).into();
199
200 let mut file_bytes = Vec::with_capacity(HEADER_LEN + rkyv_bytes.len());
201 file_bytes.extend_from_slice(fingerprint);
202 file_bytes.extend_from_slice(&payload_digest);
203 file_bytes.extend_from_slice(&rkyv_bytes);
204
205 let namespace_dir = config.namespace_dir(namespace);
206 let cache_path = config.cache_file_path(namespace, fingerprint);
207
208 crate::cache::locking::with_exclusive_cache_lock(&config.root_dir, || {
209 crate::cache::create_dir_all_private(&namespace_dir)
212 .with_context(|| "Failed to create license index cache directory")?;
213 prune_namespace_dir(&namespace_dir, &cache_path)?;
214 write_bytes_atomically(&cache_path, &file_bytes)
215 .with_context(|| "Failed to persist license index cache file")
216 })?;
217
218 Ok(())
219}
220
221pub fn delete_cache(
222 config: &LicenseCacheConfig,
223 namespace: LicenseCacheNamespace,
224 fingerprint: &[u8; 32],
225) -> Result<()> {
226 if !config.enabled {
227 return Ok(());
228 }
229
230 let cache_path = config.cache_file_path(namespace, fingerprint);
231 crate::cache::locking::with_exclusive_cache_lock(&config.root_dir, || -> Result<()> {
232 if cache_path.exists() {
233 fs::remove_file(&cache_path).context("Failed to delete license index cache file")?;
234 }
235 Ok(())
236 })?;
237
238 Ok(())
239}
240
241pub fn cache_file_size(
242 config: &LicenseCacheConfig,
243 namespace: LicenseCacheNamespace,
244 fingerprint: &[u8; 32],
245) -> Option<u64> {
246 if !config.enabled {
247 return None;
248 }
249
250 fs::metadata(config.cache_file_path(namespace, fingerprint))
251 .ok()
252 .map(|m| m.len())
253}
254
255#[cfg(test)]
256mod tests {
257 use tempfile::TempDir;
258
259 use super::*;
260 use crate::license_detection::automaton::Automaton;
261 use crate::license_detection::index::dictionary::TokenDictionary;
262
263 fn sample_cached_index() -> LicenseIndex {
264 LicenseIndex {
265 dictionary: TokenDictionary::default(),
266 len_legalese: 0,
267 rid_by_hash: Default::default(),
268 rules_by_rid: Default::default(),
269 tids_by_rid: Default::default(),
270 rules_automaton: Automaton::empty(),
271 unknown_automaton: Automaton::empty(),
272 sets_by_rid: Default::default(),
273 rule_metadata_by_identifier: Default::default(),
274 msets_by_rid: Default::default(),
275 high_sets_by_rid: Default::default(),
276 high_postings_by_rid: Default::default(),
277 licenses_by_key: Default::default(),
278 rid_by_spdx_key: Default::default(),
279 unknown_spdx_rid: None,
280 rids_by_high_tid: Default::default(),
281 spdx_license_list_version: Some("test".to_string()),
282 }
283 }
284
285 #[test]
286 fn test_cache_file_path_uses_namespace_and_fingerprint() {
287 let config = LicenseCacheConfig::new(PathBuf::from("/tmp/cache-root"), false, true);
288 let fingerprint = [0xAB; 32];
289
290 assert_eq!(
291 config.cache_file_path(LicenseCacheNamespace::Embedded, &fingerprint),
292 PathBuf::from(format!(
293 "/tmp/cache-root/license-index/embedded/{}.rkyv",
294 "ab".repeat(32)
295 ))
296 );
297 assert_eq!(
298 config.cache_file_path(LicenseCacheNamespace::CustomRules, &fingerprint),
299 PathBuf::from(format!(
300 "/tmp/cache-root/license-index/custom/{}.rkyv",
301 "ab".repeat(32)
302 ))
303 );
304 }
305
306 #[test]
307 fn test_save_cached_index_prunes_stale_namespace_entries() {
308 let temp_dir = TempDir::new().expect("create temp dir");
309 let config = LicenseCacheConfig::new(temp_dir.path().to_path_buf(), false, true);
310 let fingerprint = [0x11; 32];
311 let namespace_dir = config.namespace_dir(LicenseCacheNamespace::Embedded);
312 fs::create_dir_all(&namespace_dir).expect("create namespace dir");
313 fs::write(namespace_dir.join("stale.rkyv"), b"old").expect("write stale cache file");
314
315 let cached = sample_cached_index();
316 save_cached_index(
317 &config,
318 LicenseCacheNamespace::Embedded,
319 &cached,
320 &fingerprint,
321 )
322 .expect("save cache");
323
324 let entries = fs::read_dir(&namespace_dir)
325 .expect("read namespace dir")
326 .map(|entry| entry.expect("dir entry").path())
327 .collect::<Vec<_>>();
328
329 assert_eq!(entries.len(), 1);
330 assert_eq!(
331 entries[0],
332 config.cache_file_path(LicenseCacheNamespace::Embedded, &fingerprint)
333 );
334 }
335
336 #[test]
337 fn test_save_then_load_round_trip() {
338 let temp_dir = TempDir::new().expect("create temp dir");
339 let config = LicenseCacheConfig::new(temp_dir.path().to_path_buf(), false, true);
340 let fingerprint = [0x33; 32];
341
342 save_cached_index(
343 &config,
344 LicenseCacheNamespace::Embedded,
345 &sample_cached_index(),
346 &fingerprint,
347 )
348 .expect("save cache");
349
350 let loaded = load_cached_index(&config, LicenseCacheNamespace::Embedded, &fingerprint)
351 .expect("load cache")
352 .expect("cache hit");
353 assert_eq!(loaded.spdx_license_list_version.as_deref(), Some("test"));
354 }
355
356 #[test]
357 fn test_tampered_payload_is_rejected_before_deserialization() {
358 let temp_dir = TempDir::new().expect("create temp dir");
359 let config = LicenseCacheConfig::new(temp_dir.path().to_path_buf(), false, true);
360 let fingerprint = [0x44; 32];
361
362 save_cached_index(
363 &config,
364 LicenseCacheNamespace::Embedded,
365 &sample_cached_index(),
366 &fingerprint,
367 )
368 .expect("save cache");
369
370 let cache_path = config.cache_file_path(LicenseCacheNamespace::Embedded, &fingerprint);
371 let mut bytes = fs::read(&cache_path).expect("read cache file");
372 let last = bytes.len() - 1;
376 bytes[last] ^= 0xFF;
377 fs::write(&cache_path, &bytes).expect("rewrite tampered cache file");
378
379 let loaded = load_cached_index(&config, LicenseCacheNamespace::Embedded, &fingerprint)
380 .expect("load should not error on tamper");
381 assert!(
382 loaded.is_none(),
383 "tampered payload must be rejected as a cache miss"
384 );
385 }
386
387 #[test]
388 fn test_legacy_fingerprint_only_layout_is_treated_as_miss() {
389 let temp_dir = TempDir::new().expect("create temp dir");
390 let config = LicenseCacheConfig::new(temp_dir.path().to_path_buf(), false, true);
391 let fingerprint = [0x55; 32];
392
393 let rkyv_bytes =
397 rkyv::to_bytes::<rkyv::rancor::Error>(&sample_cached_index()).expect("serialize index");
398 let mut legacy = Vec::new();
399 legacy.extend_from_slice(&fingerprint);
400 legacy.extend_from_slice(&rkyv_bytes);
401
402 let namespace_dir = config.namespace_dir(LicenseCacheNamespace::Embedded);
403 fs::create_dir_all(&namespace_dir).expect("create namespace dir");
404 let cache_path = config.cache_file_path(LicenseCacheNamespace::Embedded, &fingerprint);
405 fs::write(&cache_path, &legacy).expect("write legacy cache file");
406
407 let loaded = load_cached_index(&config, LicenseCacheNamespace::Embedded, &fingerprint)
408 .expect("load should not error on legacy layout");
409 assert!(loaded.is_none(), "legacy layout must be treated as a miss");
410 }
411
412 #[cfg(unix)]
413 #[test]
414 fn test_saved_cache_file_and_dirs_use_restrictive_permissions() {
415 use std::os::unix::fs::PermissionsExt;
416
417 let temp_dir = TempDir::new().expect("create temp dir");
418 let config = LicenseCacheConfig::new(temp_dir.path().to_path_buf(), false, true);
419 let fingerprint = [0x66; 32];
420
421 save_cached_index(
422 &config,
423 LicenseCacheNamespace::Embedded,
424 &sample_cached_index(),
425 &fingerprint,
426 )
427 .expect("save cache");
428
429 let cache_path = config.cache_file_path(LicenseCacheNamespace::Embedded, &fingerprint);
430 let file_mode = fs::metadata(&cache_path)
431 .expect("file metadata")
432 .permissions()
433 .mode();
434 assert_eq!(file_mode & 0o777, 0o600, "cache file must be owner-only");
435
436 let namespace_dir = config.namespace_dir(LicenseCacheNamespace::Embedded);
437 let dir_mode = fs::metadata(&namespace_dir)
438 .expect("dir metadata")
439 .permissions()
440 .mode();
441 assert_eq!(dir_mode & 0o777, 0o700, "cache dir must be owner-only");
442 }
443
444 #[test]
445 fn test_disabled_cache_skips_persistence() {
446 let temp_dir = TempDir::new().expect("create temp dir");
447 let config = LicenseCacheConfig::new(temp_dir.path().to_path_buf(), false, false);
448 let fingerprint = [0x22; 32];
449
450 save_cached_index(
451 &config,
452 LicenseCacheNamespace::Embedded,
453 &sample_cached_index(),
454 &fingerprint,
455 )
456 .expect("disabled save should succeed");
457
458 assert!(
459 !config
460 .cache_file_path(LicenseCacheNamespace::Embedded, &fingerprint)
461 .exists()
462 );
463 assert!(
464 load_cached_index(&config, LicenseCacheNamespace::Embedded, &fingerprint)
465 .expect("disabled load should succeed")
466 .is_none()
467 );
468 }
469
470 #[test]
471 fn test_compute_rules_fingerprint_changes_when_rule_metadata_changes() {
472 let rule_a = LoadedRule {
473 identifier: "example.RULE".to_string(),
474 license_expression: "mit".to_string(),
475 text: "example text".to_string(),
476 rule_kind: crate::license_detection::models::RuleKind::Text,
477 is_false_positive: false,
478 is_required_phrase: false,
479 skip_for_required_phrase_generation: false,
480 relevance: Some(100),
481 minimum_coverage: None,
482 has_stored_minimum_coverage: false,
483 is_continuous: false,
484 referenced_filenames: None,
485 ignorable_urls: None,
486 ignorable_emails: None,
487 ignorable_copyrights: None,
488 ignorable_holders: None,
489 ignorable_authors: None,
490 language: None,
491 notes: None,
492 is_deprecated: false,
493 replaced_by: vec![],
494 };
495 let mut rule_b = rule_a.clone();
496 rule_b.referenced_filenames = Some(vec!["LICENSE".to_string()]);
497
498 let license = LoadedLicense {
499 key: "mit".to_string(),
500 short_name: Some("MIT".to_string()),
501 name: "MIT License".to_string(),
502 language: Some("en".to_string()),
503 spdx_license_key: Some("MIT".to_string()),
504 other_spdx_license_keys: vec![],
505 category: Some("Permissive".to_string()),
506 owner: None,
507 homepage_url: None,
508 text: "MIT text".to_string(),
509 reference_urls: vec![],
510 osi_license_key: None,
511 text_urls: vec![],
512 osi_url: None,
513 faq_url: None,
514 other_urls: vec![],
515 notes: None,
516 is_deprecated: false,
517 is_exception: false,
518 is_unknown: false,
519 is_generic: false,
520 replaced_by: vec![],
521 minimum_coverage: None,
522 standard_notice: None,
523 ignorable_copyrights: None,
524 ignorable_holders: None,
525 ignorable_authors: None,
526 ignorable_urls: None,
527 ignorable_emails: None,
528 };
529
530 let fingerprint_a = compute_rules_fingerprint(&[rule_a], std::slice::from_ref(&license))
531 .expect("fingerprint A");
532 let fingerprint_b =
533 compute_rules_fingerprint(&[rule_b], &[license]).expect("fingerprint B");
534
535 assert_ne!(fingerprint_a, fingerprint_b);
536 }
537}