Skip to main content

provenant/license_detection/embedded/
index.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::io::Read;
5
6use serde::Deserialize;
7
8use super::schema::{EmbeddedArtifactMetadata, EmbeddedLoaderSnapshot, SCHEMA_VERSION};
9use crate::license_detection::index::LicenseIndex;
10use crate::license_detection::index::build_index_from_loaded;
11
12/// Prefix of [`EmbeddedLoaderSnapshot`] covering only the leading fields needed
13/// to validate the cache and surface artifact metadata.
14///
15/// postcard serializes struct fields in declaration order with no whole-struct
16/// length framing, so deserializing this prefix from the start of the snapshot
17/// yields the same `schema_version` and `metadata` as the full snapshot without
18/// touching the (much larger) `rules` and `licenses` that follow. The field
19/// order here MUST stay in lockstep with the leading fields of
20/// [`EmbeddedLoaderSnapshot`].
21#[derive(Debug, Clone, Deserialize)]
22struct EmbeddedArtifactMetadataPrefix {
23    schema_version: u32,
24    metadata: EmbeddedArtifactMetadata,
25}
26
27/// Decompressed-prefix budget for the streaming metadata read. The metadata
28/// section is a few hundred bytes; this leaves generous headroom while keeping
29/// the read bounded so a malformed artifact cannot force decompressing the
30/// whole payload before failing.
31const METADATA_PREFIX_MAX_BYTES: usize = 64 * 1024;
32
33/// Bytes pulled from the streaming zstd decoder per growth step.
34const METADATA_PREFIX_CHUNK_BYTES: usize = 4 * 1024;
35
36// Loader handle: fields are consumed by maintainer/index-build paths, not every routine-scan path.
37#[derive(Debug, Clone)]
38#[allow(dead_code)]
39pub struct LoadedEmbeddedLicenseIndex {
40    pub index: LicenseIndex,
41    pub metadata: EmbeddedArtifactMetadata,
42}
43
44#[derive(Debug, Clone, thiserror::Error)]
45#[error("License loader artifact error: {0}")]
46pub struct SerializationError(pub String);
47
48pub fn load_loader_snapshot_from_bytes(
49    bytes: &[u8],
50) -> Result<EmbeddedLoaderSnapshot, SerializationError> {
51    if bytes.is_empty() {
52        return Err(SerializationError(
53            "Embedded license index artifact is empty".to_string(),
54        ));
55    }
56
57    let decompressed = zstd::decode_all(bytes).map_err(|e| {
58        SerializationError(format!("Failed to decompress embedded artifact: {}", e))
59    })?;
60
61    let snapshot: EmbeddedLoaderSnapshot = postcard::from_bytes(&decompressed).map_err(|e| {
62        SerializationError(format!("Failed to deserialize embedded artifact: {}", e))
63    })?;
64
65    if snapshot.schema_version != SCHEMA_VERSION {
66        return Err(SerializationError(format!(
67            "Embedded artifact schema version mismatch: expected {}, got {}",
68            SCHEMA_VERSION, snapshot.schema_version
69        )));
70    }
71
72    Ok(snapshot)
73}
74
75// Used by maintainer/index-build entry points; not every binary links this path.
76#[allow(dead_code)]
77pub fn load_embedded_license_index_from_bytes(
78    bytes: &[u8],
79) -> Result<LoadedEmbeddedLicenseIndex, SerializationError> {
80    let snapshot = load_loader_snapshot_from_bytes(bytes)?;
81    let index = build_index_from_loaded(snapshot.rules, snapshot.licenses, false);
82
83    Ok(LoadedEmbeddedLicenseIndex {
84        index,
85        metadata: snapshot.metadata,
86    })
87}
88
89/// Read just the artifact metadata without materializing the full snapshot.
90///
91/// This streams the zstd payload and postcard-decodes only the
92/// `{schema_version, metadata}` prefix, so warm-cache startup no longer pays
93/// for decompressing and deserializing all rules and licenses just to validate
94/// the cache. The schema-version check matches
95/// [`load_loader_snapshot_from_bytes`] so a stale or mismatched artifact is
96/// still rejected here.
97pub fn load_embedded_artifact_metadata_from_bytes(
98    bytes: &[u8],
99) -> Result<EmbeddedArtifactMetadata, SerializationError> {
100    if bytes.is_empty() {
101        return Err(SerializationError(
102            "Embedded license index artifact is empty".to_string(),
103        ));
104    }
105
106    let mut decoder =
107        zstd::stream::read::Decoder::new(std::io::Cursor::new(bytes)).map_err(|e| {
108            SerializationError(format!("Failed to decompress embedded artifact: {}", e))
109        })?;
110
111    // Grow a decompressed prefix until the metadata prefix decodes, the decoder
112    // hits EOF, or we exceed the bounded budget. postcard reports a recoverable
113    // "ran out of bytes" error while the prefix is incomplete; any other error
114    // is a genuine deserialization failure and must propagate.
115    let mut prefix: Vec<u8> = Vec::with_capacity(METADATA_PREFIX_CHUNK_BYTES);
116    loop {
117        match postcard::take_from_bytes::<EmbeddedArtifactMetadataPrefix>(&prefix) {
118            Ok((decoded, _rest)) => {
119                if decoded.schema_version != SCHEMA_VERSION {
120                    return Err(SerializationError(format!(
121                        "Embedded artifact schema version mismatch: expected {}, got {}",
122                        SCHEMA_VERSION, decoded.schema_version
123                    )));
124                }
125                return Ok(decoded.metadata);
126            }
127            Err(postcard::Error::DeserializeUnexpectedEnd) => {}
128            Err(e) => {
129                return Err(SerializationError(format!(
130                    "Failed to deserialize embedded artifact metadata: {}",
131                    e
132                )));
133            }
134        }
135
136        if prefix.len() >= METADATA_PREFIX_MAX_BYTES {
137            return Err(SerializationError(format!(
138                "Embedded artifact metadata exceeds {} byte prefix budget",
139                METADATA_PREFIX_MAX_BYTES
140            )));
141        }
142
143        let mut chunk = [0u8; METADATA_PREFIX_CHUNK_BYTES];
144        let read = decoder.read(&mut chunk).map_err(|e| {
145            SerializationError(format!("Failed to decompress embedded artifact: {}", e))
146        })?;
147        if read == 0 {
148            // Decoder is exhausted but the prefix still did not decode.
149            return Err(SerializationError(
150                "Failed to deserialize embedded artifact metadata: artifact ended before metadata"
151                    .to_string(),
152            ));
153        }
154        prefix.extend_from_slice(&chunk[..read]);
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::license_detection::models::{LoadedLicense, LoadedRule};
162
163    fn create_test_metadata() -> EmbeddedArtifactMetadata {
164        EmbeddedArtifactMetadata {
165            spdx_license_list_version: "3.27".to_string(),
166            license_index_provenance: crate::models::LicenseIndexProvenance {
167                source: "embedded-artifact".to_string(),
168                dataset_fingerprint: "test".to_string(),
169                ignored_rules: vec![],
170                ignored_licenses: vec![],
171                ignored_rules_due_to_licenses: vec![],
172                added_rules: vec![],
173                replaced_rules: vec![],
174                added_licenses: vec![],
175                replaced_licenses: vec![],
176            },
177        }
178    }
179
180    fn serialize_loader_snapshot_to_bytes(
181        rules: Vec<LoadedRule>,
182        licenses: Vec<LoadedLicense>,
183    ) -> Result<Vec<u8>, SerializationError> {
184        let snapshot = EmbeddedLoaderSnapshot {
185            schema_version: SCHEMA_VERSION,
186            metadata: create_test_metadata(),
187            rules,
188            licenses,
189        };
190
191        let postcard_bytes = postcard::to_allocvec(&snapshot).map_err(|e| {
192            SerializationError(format!("Failed to serialize embedded artifact: {}", e))
193        })?;
194
195        zstd::encode_all(&postcard_bytes[..], 0)
196            .map_err(|e| SerializationError(format!("Failed to compress embedded artifact: {}", e)))
197    }
198
199    fn create_test_loaded_rule() -> LoadedRule {
200        LoadedRule {
201            identifier: "test.RULE".to_string(),
202            license_expression: "mit".to_string(),
203            text: "MIT License text".to_string(),
204            rule_kind: crate::license_detection::models::RuleKind::Text,
205            is_false_positive: false,
206            is_required_phrase: false,
207            skip_for_required_phrase_generation: false,
208            relevance: Some(100),
209            minimum_coverage: None,
210            has_stored_minimum_coverage: false,
211            is_continuous: false,
212            referenced_filenames: None,
213            ignorable_urls: None,
214            ignorable_emails: None,
215            ignorable_copyrights: None,
216            ignorable_holders: None,
217            ignorable_authors: None,
218            language: None,
219            notes: None,
220            is_deprecated: false,
221            replaced_by: vec![],
222        }
223    }
224
225    fn create_test_loaded_license() -> LoadedLicense {
226        LoadedLicense {
227            key: "mit".to_string(),
228            short_name: Some("MIT".to_string()),
229            name: "MIT License".to_string(),
230            language: Some("en".to_string()),
231            spdx_license_key: Some("MIT".to_string()),
232            other_spdx_license_keys: vec![],
233            category: Some("Permissive".to_string()),
234            owner: None,
235            homepage_url: None,
236            text: "MIT License text".to_string(),
237            reference_urls: vec![],
238            osi_license_key: None,
239            text_urls: vec![],
240            osi_url: None,
241            faq_url: None,
242            other_urls: vec![],
243            notes: None,
244            is_deprecated: false,
245            is_exception: false,
246            is_unknown: false,
247            is_generic: false,
248            replaced_by: vec![],
249            minimum_coverage: None,
250            standard_notice: None,
251            ignorable_copyrights: None,
252            ignorable_holders: None,
253            ignorable_authors: None,
254            ignorable_urls: None,
255            ignorable_emails: None,
256        }
257    }
258
259    #[test]
260    fn test_load_license_index_from_bytes_roundtrip() {
261        let bytes = serialize_loader_snapshot_to_bytes(
262            vec![create_test_loaded_rule()],
263            vec![create_test_loaded_license()],
264        )
265        .expect("Should serialize");
266
267        let index = load_embedded_license_index_from_bytes(&bytes)
268            .expect("Should deserialize")
269            .index;
270
271        assert_eq!(index.licenses_by_key.len(), 1);
272        assert!(
273            index
274                .rules_by_rid
275                .iter()
276                .any(|rule| rule.identifier == "test.RULE"),
277            "runtime index should retain the serialized rule"
278        );
279        assert!(
280            index
281                .rules_by_rid
282                .iter()
283                .any(|rule| rule.identifier == "mit.LICENSE"),
284            "runtime index should synthesize a license-derived rule"
285        );
286    }
287
288    #[test]
289    fn test_load_embedded_artifact_metadata_from_bytes_roundtrip() {
290        let bytes = serialize_loader_snapshot_to_bytes(
291            vec![create_test_loaded_rule()],
292            vec![create_test_loaded_license()],
293        )
294        .expect("Should serialize");
295
296        let metadata = load_embedded_artifact_metadata_from_bytes(&bytes)
297            .expect("Should deserialize metadata");
298
299        assert_eq!(metadata.spdx_license_list_version, "3.27");
300        assert_eq!(
301            metadata.license_index_provenance.source,
302            "embedded-artifact"
303        );
304    }
305
306    #[test]
307    fn test_load_license_index_from_bytes_rejects_empty() {
308        let error = load_embedded_license_index_from_bytes(&[]).unwrap_err();
309        assert!(error.to_string().contains("artifact is empty"));
310    }
311
312    #[test]
313    fn test_metadata_prefix_matches_full_decode() {
314        // Use enough rules/licenses that the trailing sections clearly dwarf the
315        // metadata, so the prefix read cannot accidentally consume everything.
316        let rules: Vec<LoadedRule> = (0..256).map(|_| create_test_loaded_rule()).collect();
317        let licenses: Vec<LoadedLicense> = (0..256).map(|_| create_test_loaded_license()).collect();
318        let bytes = serialize_loader_snapshot_to_bytes(rules, licenses).expect("Should serialize");
319
320        let prefix_metadata =
321            load_embedded_artifact_metadata_from_bytes(&bytes).expect("prefix read should succeed");
322        let full_metadata = load_loader_snapshot_from_bytes(&bytes)
323            .expect("full decode should succeed")
324            .metadata;
325
326        assert_eq!(prefix_metadata, full_metadata);
327    }
328
329    #[test]
330    fn test_metadata_prefix_rejects_empty() {
331        let error = load_embedded_artifact_metadata_from_bytes(&[]).unwrap_err();
332        assert!(error.to_string().contains("artifact is empty"));
333    }
334
335    #[test]
336    fn test_metadata_prefix_rejects_schema_version_mismatch() {
337        let snapshot = EmbeddedLoaderSnapshot {
338            schema_version: SCHEMA_VERSION + 1,
339            metadata: create_test_metadata(),
340            rules: vec![create_test_loaded_rule()],
341            licenses: vec![create_test_loaded_license()],
342        };
343        let postcard_bytes = postcard::to_allocvec(&snapshot).expect("Should serialize");
344        let bytes = zstd::encode_all(&postcard_bytes[..], 0).expect("Should compress");
345
346        let error = load_embedded_artifact_metadata_from_bytes(&bytes).unwrap_err();
347        assert!(
348            error.to_string().contains("schema version mismatch"),
349            "expected schema mismatch, got: {error}"
350        );
351    }
352
353    #[test]
354    fn test_metadata_prefix_rejects_garbage() {
355        let bytes =
356            zstd::encode_all(&b"not a valid postcard snapshot"[..], 0).expect("Should compress");
357        let error = load_embedded_artifact_metadata_from_bytes(&bytes).unwrap_err();
358        assert!(
359            error.to_string().contains("deserialize"),
360            "expected deserialize failure, got: {error}"
361        );
362    }
363}