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_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        // Flip a payload byte (past the fingerprint + digest header) so the
374        // stored digest no longer matches; the load must treat this as a miss
375        // rather than feeding the tampered bytes into any deserializer.
376        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        // Simulate an old cache file written before the payload-digest header:
395        // [fingerprint][rkyv payload] with no digest. It must be a miss, not an
396        // error, so it is rebuilt transparently.
397        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}