Skip to main content

provenant/license_detection/
license_cache.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use 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
18/// On-disk cache layout: `[32-byte rules fingerprint][32-byte payload digest][rkyv payload]`.
19///
20/// The fingerprint identifies which rules/licenses the payload was built from
21/// (a derivable constant, used to select and validate the cache file). The
22/// payload digest is a SHA-256 over the rkyv payload bytes and is the actual
23/// integrity check: it is verified before any deserialization so tampered or
24/// truncated payloads are rejected before reaching the rkyv/automaton decoders.
25const 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    // A header is required for the fingerprint + payload digest. Files shorter
147    // than the header (including the old fingerprint-only layout) are treated as
148    // a miss so they are transparently rebuilt rather than erroring.
149    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    // Integrity check over the actual payload bytes, performed before any
162    // deserialization. A mismatch (tamper, truncation, partial write, or a
163    // stale layout) is treated as a miss so the index is rebuilt safely.
164    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    // Use the fallible strategy so a payload whose automaton bytes fail the
176    // checked daachorse deserializer surfaces as a miss instead of a panic.
177    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    // Layout: [fingerprint][SHA-256(payload)][payload]. The digest is verified
197    // on load before any deserialization (see `load_cached_index`).
198    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        // Cache entries can hold license/copyright text and file paths from
210        // private repositories, so restrict the directory tree to the owner.
211        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        // Flip a payload byte (past the fingerprint + digest header) so the
373        // stored digest no longer matches; the load must treat this as a miss
374        // rather than feeding the tampered bytes into any deserializer.
375        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        // Simulate an old cache file written before the payload-digest header:
394        // [fingerprint][rkyv payload] with no digest. It must be a miss, not an
395        // error, so it is rebuilt transparently.
396        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}