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_bitsets_by_rid: Default::default(),
277 high_postings_by_rid: Default::default(),
278 licenses_by_key: Default::default(),
279 rid_by_spdx_key: Default::default(),
280 unknown_spdx_rid: None,
281 rids_by_high_tid: Default::default(),
282 spdx_license_list_version: Some("test".to_string()),
283 }
284 }
285
286 #[test]
287 fn test_cache_file_path_uses_namespace_and_fingerprint() {
288 let config = LicenseCacheConfig::new(PathBuf::from("/tmp/cache-root"), false, true);
289 let fingerprint = [0xAB; 32];
290
291 assert_eq!(
292 config.cache_file_path(LicenseCacheNamespace::Embedded, &fingerprint),
293 PathBuf::from(format!(
294 "/tmp/cache-root/license-index/embedded/{}.rkyv",
295 "ab".repeat(32)
296 ))
297 );
298 assert_eq!(
299 config.cache_file_path(LicenseCacheNamespace::CustomRules, &fingerprint),
300 PathBuf::from(format!(
301 "/tmp/cache-root/license-index/custom/{}.rkyv",
302 "ab".repeat(32)
303 ))
304 );
305 }
306
307 #[test]
308 fn test_save_cached_index_prunes_stale_namespace_entries() {
309 let temp_dir = TempDir::new().expect("create temp dir");
310 let config = LicenseCacheConfig::new(temp_dir.path().to_path_buf(), false, true);
311 let fingerprint = [0x11; 32];
312 let namespace_dir = config.namespace_dir(LicenseCacheNamespace::Embedded);
313 fs::create_dir_all(&namespace_dir).expect("create namespace dir");
314 fs::write(namespace_dir.join("stale.rkyv"), b"old").expect("write stale cache file");
315
316 let cached = sample_cached_index();
317 save_cached_index(
318 &config,
319 LicenseCacheNamespace::Embedded,
320 &cached,
321 &fingerprint,
322 )
323 .expect("save cache");
324
325 let entries = fs::read_dir(&namespace_dir)
326 .expect("read namespace dir")
327 .map(|entry| entry.expect("dir entry").path())
328 .collect::<Vec<_>>();
329
330 assert_eq!(entries.len(), 1);
331 assert_eq!(
332 entries[0],
333 config.cache_file_path(LicenseCacheNamespace::Embedded, &fingerprint)
334 );
335 }
336
337 #[test]
338 fn test_save_then_load_round_trip() {
339 let temp_dir = TempDir::new().expect("create temp dir");
340 let config = LicenseCacheConfig::new(temp_dir.path().to_path_buf(), false, true);
341 let fingerprint = [0x33; 32];
342
343 save_cached_index(
344 &config,
345 LicenseCacheNamespace::Embedded,
346 &sample_cached_index(),
347 &fingerprint,
348 )
349 .expect("save cache");
350
351 let loaded = load_cached_index(&config, LicenseCacheNamespace::Embedded, &fingerprint)
352 .expect("load cache")
353 .expect("cache hit");
354 assert_eq!(loaded.spdx_license_list_version.as_deref(), Some("test"));
355 }
356
357 #[test]
358 fn test_tampered_payload_is_rejected_before_deserialization() {
359 let temp_dir = TempDir::new().expect("create temp dir");
360 let config = LicenseCacheConfig::new(temp_dir.path().to_path_buf(), false, true);
361 let fingerprint = [0x44; 32];
362
363 save_cached_index(
364 &config,
365 LicenseCacheNamespace::Embedded,
366 &sample_cached_index(),
367 &fingerprint,
368 )
369 .expect("save cache");
370
371 let cache_path = config.cache_file_path(LicenseCacheNamespace::Embedded, &fingerprint);
372 let mut bytes = fs::read(&cache_path).expect("read cache file");
373 let last = bytes.len() - 1;
377 bytes[last] ^= 0xFF;
378 fs::write(&cache_path, &bytes).expect("rewrite tampered cache file");
379
380 let loaded = load_cached_index(&config, LicenseCacheNamespace::Embedded, &fingerprint)
381 .expect("load should not error on tamper");
382 assert!(
383 loaded.is_none(),
384 "tampered payload must be rejected as a cache miss"
385 );
386 }
387
388 #[test]
389 fn test_legacy_fingerprint_only_layout_is_treated_as_miss() {
390 let temp_dir = TempDir::new().expect("create temp dir");
391 let config = LicenseCacheConfig::new(temp_dir.path().to_path_buf(), false, true);
392 let fingerprint = [0x55; 32];
393
394 let rkyv_bytes =
398 rkyv::to_bytes::<rkyv::rancor::Error>(&sample_cached_index()).expect("serialize index");
399 let mut legacy = Vec::new();
400 legacy.extend_from_slice(&fingerprint);
401 legacy.extend_from_slice(&rkyv_bytes);
402
403 let namespace_dir = config.namespace_dir(LicenseCacheNamespace::Embedded);
404 fs::create_dir_all(&namespace_dir).expect("create namespace dir");
405 let cache_path = config.cache_file_path(LicenseCacheNamespace::Embedded, &fingerprint);
406 fs::write(&cache_path, &legacy).expect("write legacy cache file");
407
408 let loaded = load_cached_index(&config, LicenseCacheNamespace::Embedded, &fingerprint)
409 .expect("load should not error on legacy layout");
410 assert!(loaded.is_none(), "legacy layout must be treated as a miss");
411 }
412
413 #[cfg(unix)]
414 #[test]
415 fn test_saved_cache_file_and_dirs_use_restrictive_permissions() {
416 use std::os::unix::fs::PermissionsExt;
417
418 let temp_dir = TempDir::new().expect("create temp dir");
419 let config = LicenseCacheConfig::new(temp_dir.path().to_path_buf(), false, true);
420 let fingerprint = [0x66; 32];
421
422 save_cached_index(
423 &config,
424 LicenseCacheNamespace::Embedded,
425 &sample_cached_index(),
426 &fingerprint,
427 )
428 .expect("save cache");
429
430 let cache_path = config.cache_file_path(LicenseCacheNamespace::Embedded, &fingerprint);
431 let file_mode = fs::metadata(&cache_path)
432 .expect("file metadata")
433 .permissions()
434 .mode();
435 assert_eq!(file_mode & 0o777, 0o600, "cache file must be owner-only");
436
437 let namespace_dir = config.namespace_dir(LicenseCacheNamespace::Embedded);
438 let dir_mode = fs::metadata(&namespace_dir)
439 .expect("dir metadata")
440 .permissions()
441 .mode();
442 assert_eq!(dir_mode & 0o777, 0o700, "cache dir must be owner-only");
443 }
444
445 #[test]
446 fn test_disabled_cache_skips_persistence() {
447 let temp_dir = TempDir::new().expect("create temp dir");
448 let config = LicenseCacheConfig::new(temp_dir.path().to_path_buf(), false, false);
449 let fingerprint = [0x22; 32];
450
451 save_cached_index(
452 &config,
453 LicenseCacheNamespace::Embedded,
454 &sample_cached_index(),
455 &fingerprint,
456 )
457 .expect("disabled save should succeed");
458
459 assert!(
460 !config
461 .cache_file_path(LicenseCacheNamespace::Embedded, &fingerprint)
462 .exists()
463 );
464 assert!(
465 load_cached_index(&config, LicenseCacheNamespace::Embedded, &fingerprint)
466 .expect("disabled load should succeed")
467 .is_none()
468 );
469 }
470
471 #[test]
472 fn test_compute_rules_fingerprint_changes_when_rule_metadata_changes() {
473 let rule_a = LoadedRule {
474 identifier: "example.RULE".to_string(),
475 license_expression: "mit".to_string(),
476 text: "example text".to_string(),
477 rule_kind: crate::license_detection::models::RuleKind::Text,
478 is_false_positive: false,
479 is_required_phrase: false,
480 skip_for_required_phrase_generation: false,
481 relevance: Some(100),
482 minimum_coverage: None,
483 has_stored_minimum_coverage: false,
484 is_continuous: false,
485 referenced_filenames: None,
486 ignorable_urls: None,
487 ignorable_emails: None,
488 ignorable_copyrights: None,
489 ignorable_holders: None,
490 ignorable_authors: None,
491 language: None,
492 notes: None,
493 is_deprecated: false,
494 replaced_by: vec![],
495 };
496 let mut rule_b = rule_a.clone();
497 rule_b.referenced_filenames = Some(vec!["LICENSE".to_string()]);
498
499 let license = LoadedLicense {
500 key: "mit".to_string(),
501 short_name: Some("MIT".to_string()),
502 name: "MIT License".to_string(),
503 language: Some("en".to_string()),
504 spdx_license_key: Some("MIT".to_string()),
505 other_spdx_license_keys: vec![],
506 category: Some("Permissive".to_string()),
507 owner: None,
508 homepage_url: None,
509 text: "MIT text".to_string(),
510 reference_urls: vec![],
511 osi_license_key: None,
512 text_urls: vec![],
513 osi_url: None,
514 faq_url: None,
515 other_urls: vec![],
516 notes: None,
517 is_deprecated: false,
518 is_exception: false,
519 is_unknown: false,
520 is_generic: false,
521 replaced_by: vec![],
522 minimum_coverage: None,
523 standard_notice: None,
524 ignorable_copyrights: None,
525 ignorable_holders: None,
526 ignorable_authors: None,
527 ignorable_urls: None,
528 ignorable_emails: None,
529 };
530
531 let fingerprint_a = compute_rules_fingerprint(&[rule_a], std::slice::from_ref(&license))
532 .expect("fingerprint A");
533 let fingerprint_b =
534 compute_rules_fingerprint(&[rule_b], &[license]).expect("fingerprint B");
535
536 assert_ne!(fingerprint_a, fingerprint_b);
537 }
538}