Skip to main content

termivar_scanner/wordpress_review/
inventory.rs

1use std::collections::BTreeSet;
2
3use serde::Deserialize;
4use serde_json::Value;
5use url::Url;
6
7use super::{
8    parse_bounded_json, validate_root_url, validate_source_version, WordPressActivationState,
9    WordPressComponentIdentity, WordPressComponentKind, WordPressContext,
10    WordPressContextComponent, WordPressReviewError, MAX_CONTEXT_JSON_NODES,
11    MAX_WORDPRESS_CONTEXT_COMPONENTS, MAX_WORDPRESS_SLUG_BYTES,
12};
13
14/// Total bytes accepted across all saved WordPress inventory inputs.
15pub const MAX_WORDPRESS_SAVED_INVENTORY_BYTES: usize = 1024 * 1024;
16const MAX_WORDPRESS_INVENTORY_INFORMATIONAL_BYTES: usize = 128;
17
18#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
19pub enum WordPressLocalInputClass {
20    PluginsJson,
21    ThemesJson,
22    CoreVersionFile,
23}
24
25#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
26pub struct WordPressLocalInputProvenance {
27    class: WordPressLocalInputClass,
28    byte_length: usize,
29    sha256: [u8; 32],
30}
31
32impl WordPressLocalInputProvenance {
33    #[must_use]
34    pub const fn new(
35        class: WordPressLocalInputClass,
36        byte_length: usize,
37        sha256: [u8; 32],
38    ) -> Self {
39        Self {
40            class,
41            byte_length,
42            sha256,
43        }
44    }
45
46    #[must_use]
47    pub const fn class(&self) -> WordPressLocalInputClass {
48        self.class
49    }
50
51    #[must_use]
52    pub const fn byte_length(&self) -> usize {
53        self.byte_length
54    }
55
56    #[must_use]
57    pub const fn sha256(&self) -> &[u8; 32] {
58        &self.sha256
59    }
60}
61
62#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
63pub enum WordPressInventoryCategoryStatus {
64    NotSupplied,
65    Supplied,
66}
67
68#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
69pub enum WordPressInventoryEntryStatus {
70    CoreVersionSupplied,
71    Active,
72    Inactive,
73    NetworkActive,
74    MustUse,
75    DropIn,
76    Parent,
77}
78
79impl WordPressInventoryEntryStatus {
80    pub(super) const fn activation(self) -> Option<WordPressActivationState> {
81        match self {
82            Self::Active => Some(WordPressActivationState::Active),
83            Self::Inactive => Some(WordPressActivationState::Inactive),
84            Self::NetworkActive => Some(WordPressActivationState::NetworkActive),
85            Self::CoreVersionSupplied | Self::MustUse | Self::DropIn | Self::Parent => None,
86        }
87    }
88}
89
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct WordPressSavedInventoryCoverage {
92    core: WordPressInventoryCategoryStatus,
93    plugins: WordPressInventoryCategoryStatus,
94    themes: WordPressInventoryCategoryStatus,
95}
96
97impl WordPressSavedInventoryCoverage {
98    #[must_use]
99    pub const fn core(&self) -> WordPressInventoryCategoryStatus {
100        self.core
101    }
102
103    #[must_use]
104    pub const fn plugins(&self) -> WordPressInventoryCategoryStatus {
105        self.plugins
106    }
107
108    #[must_use]
109    pub const fn themes(&self) -> WordPressInventoryCategoryStatus {
110        self.themes
111    }
112}
113
114#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
115pub enum WordPressInventoryLimitationReason {
116    DropInIdentityIsNotCatalogSlug,
117}
118
119#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
120pub struct WordPressInventoryLimitation {
121    declared_name: String,
122    version: Option<String>,
123    status: WordPressInventoryEntryStatus,
124    reason: WordPressInventoryLimitationReason,
125}
126
127impl WordPressInventoryLimitation {
128    #[must_use]
129    pub fn declared_name(&self) -> &str {
130        &self.declared_name
131    }
132
133    #[must_use]
134    pub const fn status(&self) -> WordPressInventoryEntryStatus {
135        self.status
136    }
137
138    #[must_use]
139    pub fn version(&self) -> Option<&str> {
140        self.version.as_deref()
141    }
142
143    #[must_use]
144    pub const fn reason(&self) -> WordPressInventoryLimitationReason {
145        self.reason
146    }
147}
148
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct WordPressSavedInventorySummary {
151    coverage: WordPressSavedInventoryCoverage,
152    component_count: usize,
153    limitations: Vec<WordPressInventoryLimitation>,
154}
155
156impl WordPressSavedInventorySummary {
157    #[must_use]
158    pub const fn coverage(&self) -> &WordPressSavedInventoryCoverage {
159        &self.coverage
160    }
161
162    #[must_use]
163    pub const fn component_count(&self) -> usize {
164        self.component_count
165    }
166
167    #[must_use]
168    pub fn limitations(&self) -> &[WordPressInventoryLimitation] {
169        &self.limitations
170    }
171}
172
173#[derive(Clone, Debug, Eq, PartialEq)]
174pub struct WordPressSavedInventory {
175    context: WordPressContext,
176    summary: WordPressSavedInventorySummary,
177    expected_inputs: Vec<(WordPressLocalInputClass, usize)>,
178}
179
180impl WordPressSavedInventory {
181    #[must_use]
182    pub const fn summary(&self) -> &WordPressSavedInventorySummary {
183        &self.summary
184    }
185
186    pub(super) fn into_parts(
187        self,
188    ) -> (
189        WordPressContext,
190        WordPressSavedInventorySummary,
191        Vec<(WordPressLocalInputClass, usize)>,
192    ) {
193        (self.context, self.summary, self.expected_inputs)
194    }
195}
196
197/// Parses explicitly selected, already-saved WP-CLI inventory bytes.
198///
199/// The caller owns filesystem acquisition. This pure parser neither executes
200/// WP-CLI nor authenticates that the inputs describe `root`.
201pub fn parse_wordpress_saved_inventory(
202    root: Url,
203    plugins: Option<&[u8]>,
204    themes: Option<&[u8]>,
205    core: Option<&[u8]>,
206) -> Result<WordPressSavedInventory, WordPressReviewError> {
207    if plugins.is_none() && themes.is_none() && core.is_none() {
208        return Err(WordPressReviewError::EmptyInput);
209    }
210    let total_bytes = [plugins, themes, core]
211        .into_iter()
212        .flatten()
213        .try_fold(0_usize, |total, bytes| total.checked_add(bytes.len()))
214        .ok_or(WordPressReviewError::InventoryTooLarge)?;
215    if total_bytes > MAX_WORDPRESS_SAVED_INVENTORY_BYTES {
216        return Err(WordPressReviewError::InventoryTooLarge);
217    }
218    let root_url = validate_root_url(root.as_str())?;
219    let mut expected_inputs = Vec::with_capacity(3);
220    let mut rows = Vec::new();
221    let mut limitations = Vec::new();
222
223    if let Some(bytes) = plugins {
224        expected_inputs.push((WordPressLocalInputClass::PluginsJson, bytes.len()));
225        parse_component_rows(
226            bytes,
227            WordPressComponentKind::Plugin,
228            &mut rows,
229            &mut limitations,
230        )?;
231    }
232    if let Some(bytes) = themes {
233        expected_inputs.push((WordPressLocalInputClass::ThemesJson, bytes.len()));
234        parse_component_rows(
235            bytes,
236            WordPressComponentKind::Theme,
237            &mut rows,
238            &mut limitations,
239        )?;
240    }
241    if let Some(bytes) = core {
242        expected_inputs.push((WordPressLocalInputClass::CoreVersionFile, bytes.len()));
243        let version = parse_core_version(bytes)?;
244        rows.push(SavedComponent {
245            identity: WordPressComponentIdentity::core(),
246            version: Some(version),
247            status: WordPressInventoryEntryStatus::CoreVersionSupplied,
248        });
249    }
250
251    if rows
252        .len()
253        .checked_add(limitations.len())
254        .is_none_or(|count| count > MAX_WORDPRESS_CONTEXT_COMPONENTS)
255    {
256        return Err(WordPressReviewError::InventoryComponentLimitExceeded);
257    }
258    let mut identities = BTreeSet::new();
259    for row in &rows {
260        if !identities.insert(row.identity.clone()) {
261            return Err(WordPressReviewError::DuplicateComponent);
262        }
263    }
264    rows.sort_by(|left, right| left.identity.cmp(&right.identity));
265    limitations.sort();
266    let mut limitation_names = BTreeSet::new();
267    if limitations
268        .iter()
269        .any(|limitation| !limitation_names.insert(limitation.declared_name.as_str()))
270    {
271        return Err(WordPressReviewError::DuplicateComponent);
272    }
273    expected_inputs.sort_by_key(|(class, _)| *class);
274
275    let components = rows
276        .into_iter()
277        .map(|row| WordPressContextComponent {
278            identity: row.identity,
279            version: row.version,
280            activation: row.status.activation(),
281            patches: Vec::new(),
282            inventory_status: Some(row.status),
283        })
284        .collect::<Vec<_>>();
285    let summary = WordPressSavedInventorySummary {
286        coverage: WordPressSavedInventoryCoverage {
287            core: category_status(core),
288            plugins: category_status(plugins),
289            themes: category_status(themes),
290        },
291        component_count: components.len(),
292        limitations,
293    };
294    Ok(WordPressSavedInventory {
295        context: WordPressContext {
296            root_url,
297            hosting_os: None,
298            multisite: None,
299            components,
300        },
301        summary,
302        expected_inputs,
303    })
304}
305
306pub(super) fn validate_inventory_provenance(
307    expected_inputs: &[(WordPressLocalInputClass, usize)],
308    mut provenance: Vec<WordPressLocalInputProvenance>,
309) -> Result<Vec<WordPressLocalInputProvenance>, WordPressReviewError> {
310    provenance.sort();
311    if provenance.len() != expected_inputs.len()
312        || provenance
313            .windows(2)
314            .any(|pair| pair[0].class == pair[1].class)
315        || provenance
316            .iter()
317            .zip(expected_inputs)
318            .any(|(actual, expected)| {
319                actual.class != expected.0 || actual.byte_length != expected.1
320            })
321    {
322        return Err(WordPressReviewError::InvalidInventoryProvenance);
323    }
324    Ok(provenance)
325}
326
327const fn category_status(bytes: Option<&[u8]>) -> WordPressInventoryCategoryStatus {
328    if bytes.is_some() {
329        WordPressInventoryCategoryStatus::Supplied
330    } else {
331        WordPressInventoryCategoryStatus::NotSupplied
332    }
333}
334
335#[derive(Deserialize)]
336#[serde(deny_unknown_fields)]
337struct InventoryRowWire {
338    name: String,
339    status: String,
340    version: String,
341    #[serde(default)]
342    update: Option<InventoryInformationalWire>,
343    #[serde(default)]
344    update_version: Option<InventoryInformationalWire>,
345    #[serde(default)]
346    auto_update: Option<InventoryInformationalWire>,
347}
348
349#[derive(Deserialize)]
350#[serde(untagged)]
351enum InventoryInformationalWire {
352    String(String),
353    Bool(bool),
354}
355
356struct SavedComponent {
357    identity: WordPressComponentIdentity,
358    version: Option<String>,
359    status: WordPressInventoryEntryStatus,
360}
361
362fn parse_component_rows(
363    bytes: &[u8],
364    kind: WordPressComponentKind,
365    rows: &mut Vec<SavedComponent>,
366    limitations: &mut Vec<WordPressInventoryLimitation>,
367) -> Result<(), WordPressReviewError> {
368    let retained = rows
369        .len()
370        .checked_add(limitations.len())
371        .ok_or(WordPressReviewError::InventoryComponentLimitExceeded)?;
372    let remaining = MAX_WORDPRESS_CONTEXT_COMPONENTS
373        .checked_sub(retained)
374        .ok_or(WordPressReviewError::InventoryComponentLimitExceeded)?;
375    let value = parse_bounded_json(
376        bytes,
377        MAX_WORDPRESS_SAVED_INVENTORY_BYTES,
378        MAX_CONTEXT_JSON_NODES,
379        remaining,
380        WordPressReviewError::InventoryTooLarge,
381    )?;
382    if !matches!(value, Value::Array(_)) {
383        return Err(WordPressReviewError::InvalidInventory);
384    }
385    let parsed: Vec<InventoryRowWire> =
386        serde_json::from_value(value).map_err(|_| WordPressReviewError::InvalidInventory)?;
387    let mut declared_names = BTreeSet::new();
388    if parsed
389        .iter()
390        .any(|entry| !declared_names.insert(entry.name.as_str()))
391    {
392        return Err(WordPressReviewError::DuplicateComponent);
393    }
394    for entry in parsed {
395        let status = parse_status(kind, &entry.status)?;
396        // These documented ordinary WP-CLI projection fields are deliberately
397        // accepted but are not installation identity or version evidence.
398        for informational in [
399            entry.update.as_ref(),
400            entry.update_version.as_ref(),
401            entry.auto_update.as_ref(),
402        ] {
403            validate_discarded_informational(informational)?;
404        }
405        let version = parse_inventory_version(&entry.version, status)?;
406        if status == WordPressInventoryEntryStatus::DropIn {
407            validate_drop_in_name(&entry.name)?;
408            limitations.push(WordPressInventoryLimitation {
409                declared_name: entry.name,
410                version,
411                status,
412                reason: WordPressInventoryLimitationReason::DropInIdentityIsNotCatalogSlug,
413            });
414            continue;
415        }
416        let identity = WordPressComponentIdentity::new(kind, entry.name)
417            .map_err(|_| WordPressReviewError::InvalidInventory)?;
418        rows.push(SavedComponent {
419            identity,
420            version,
421            status,
422        });
423    }
424    Ok(())
425}
426
427fn parse_status(
428    kind: WordPressComponentKind,
429    value: &str,
430) -> Result<WordPressInventoryEntryStatus, WordPressReviewError> {
431    let status = match (kind, value) {
432        (_, "active") => WordPressInventoryEntryStatus::Active,
433        (_, "inactive") => WordPressInventoryEntryStatus::Inactive,
434        (WordPressComponentKind::Plugin, "active-network") => {
435            WordPressInventoryEntryStatus::NetworkActive
436        },
437        (WordPressComponentKind::Plugin, "must-use") => WordPressInventoryEntryStatus::MustUse,
438        (WordPressComponentKind::Plugin, "dropin") => WordPressInventoryEntryStatus::DropIn,
439        (WordPressComponentKind::Theme, "parent") => WordPressInventoryEntryStatus::Parent,
440        _ => return Err(WordPressReviewError::InvalidInventory),
441    };
442    Ok(status)
443}
444
445fn validate_drop_in_name(value: &str) -> Result<(), WordPressReviewError> {
446    if value.is_empty()
447        || value.len() > MAX_WORDPRESS_SLUG_BYTES
448        || matches!(value, "." | "..")
449        || !value
450            .bytes()
451            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
452    {
453        return Err(WordPressReviewError::InvalidInventory);
454    }
455    Ok(())
456}
457
458fn validate_discarded_informational(
459    value: Option<&InventoryInformationalWire>,
460) -> Result<(), WordPressReviewError> {
461    match value {
462        Some(InventoryInformationalWire::String(value))
463            if value.len() > MAX_WORDPRESS_INVENTORY_INFORMATIONAL_BYTES
464                || value.chars().any(char::is_control) =>
465        {
466            Err(WordPressReviewError::InvalidInventory)
467        },
468        Some(InventoryInformationalWire::Bool(value)) => {
469            let _ = *value;
470            Ok(())
471        },
472        Some(InventoryInformationalWire::String(_)) | None => Ok(()),
473    }
474}
475
476fn parse_core_version(bytes: &[u8]) -> Result<String, WordPressReviewError> {
477    if bytes.is_empty() || bytes.len() > super::MAX_WORDPRESS_VERSION_BYTES + 2 {
478        return Err(WordPressReviewError::InvalidInventory);
479    }
480    let line = if let Some(line) = bytes.strip_suffix(b"\r\n") {
481        line
482    } else if let Some(line) = bytes.strip_suffix(b"\n") {
483        line
484    } else {
485        bytes
486    };
487    if line.contains(&b'\n') || line.contains(&b'\r') {
488        return Err(WordPressReviewError::InvalidInventory);
489    }
490    let line = std::str::from_utf8(line).map_err(|_| WordPressReviewError::InvalidInventory)?;
491    if line.chars().any(char::is_whitespace) {
492        return Err(WordPressReviewError::InvalidInventory);
493    }
494    validate_source_version(line).map_err(|_| WordPressReviewError::InvalidInventory)
495}
496
497fn parse_inventory_version(
498    value: &str,
499    status: WordPressInventoryEntryStatus,
500) -> Result<Option<String>, WordPressReviewError> {
501    if value.is_empty() {
502        return if matches!(
503            status,
504            WordPressInventoryEntryStatus::MustUse | WordPressInventoryEntryStatus::DropIn
505        ) {
506            Ok(None)
507        } else {
508            Err(WordPressReviewError::InvalidInventory)
509        };
510    }
511    if value.chars().any(char::is_whitespace) {
512        return Err(WordPressReviewError::InvalidInventory);
513    }
514    validate_source_version(value)
515        .map(Some)
516        .map_err(|_| WordPressReviewError::InvalidInventory)
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use crate::wordpress_review::{
523        evaluate_wordpress_review, parse_wordpress_context, WordPressComponentEvidenceClass,
524        WordPressEvidenceSource, WordPressReviewInputs,
525    };
526
527    const PLUGINS: &str = r#"[
528      {"name":"zeta-plugin","status":"inactive","version":"vendor-release-x",
529       "update":"none","update_version":"","auto_update":"off"},
530      {"name":"network-plugin","status":"active-network","version":"2.0"},
531      {"name":"must-use-plugin","status":"must-use","version":""},
532      {"name":"object-cache.php","status":"dropin","version":""}
533    ]"#;
534    const THEMES: &str = r#"[
535      {"name":"parent-theme","status":"parent","version":"3.1"},
536      {"name":"active-theme","status":"active","version":"4.0"}
537    ]"#;
538    const CORE: &[u8] = b"6.9.4-beta1\r\n";
539
540    fn root() -> Url {
541        Url::parse("https://example.test/").unwrap()
542    }
543
544    fn provenance(
545        plugins: Option<&[u8]>,
546        themes: Option<&[u8]>,
547        core: Option<&[u8]>,
548    ) -> Vec<WordPressLocalInputProvenance> {
549        let mut result = Vec::new();
550        if let Some(bytes) = core {
551            result.push(WordPressLocalInputProvenance::new(
552                WordPressLocalInputClass::CoreVersionFile,
553                bytes.len(),
554                [3; 32],
555            ));
556        }
557        if let Some(bytes) = themes {
558            result.push(WordPressLocalInputProvenance::new(
559                WordPressLocalInputClass::ThemesJson,
560                bytes.len(),
561                [2; 32],
562            ));
563        }
564        if let Some(bytes) = plugins {
565            result.push(WordPressLocalInputProvenance::new(
566                WordPressLocalInputClass::PluginsJson,
567                bytes.len(),
568                [1; 32],
569            ));
570        }
571        result
572    }
573
574    #[test]
575    fn saved_inventory_preserves_coverage_statuses_limitations_and_provenance() {
576        let plugin_bytes = PLUGINS.as_bytes();
577        let theme_bytes = THEMES.as_bytes();
578        let inventory = parse_wordpress_saved_inventory(
579            root(),
580            Some(plugin_bytes),
581            Some(theme_bytes),
582            Some(CORE),
583        )
584        .unwrap();
585
586        assert_eq!(
587            inventory.summary().coverage().plugins(),
588            WordPressInventoryCategoryStatus::Supplied
589        );
590        assert_eq!(
591            inventory.summary().coverage().themes(),
592            WordPressInventoryCategoryStatus::Supplied
593        );
594        assert_eq!(
595            inventory.summary().coverage().core(),
596            WordPressInventoryCategoryStatus::Supplied
597        );
598        assert_eq!(inventory.summary().component_count(), 6);
599        assert_eq!(inventory.summary().limitations().len(), 1);
600        let limitation = &inventory.summary().limitations()[0];
601        assert_eq!(limitation.declared_name(), "object-cache.php");
602        assert_eq!(limitation.status(), WordPressInventoryEntryStatus::DropIn);
603        assert_eq!(limitation.version(), None);
604        assert_eq!(
605            limitation.reason(),
606            WordPressInventoryLimitationReason::DropInIdentityIsNotCatalogSlug
607        );
608
609        let inputs = WordPressReviewInputs::from_saved_inventory(
610            inventory,
611            None,
612            provenance(Some(plugin_bytes), Some(theme_bytes), Some(CORE)),
613        )
614        .unwrap();
615        let result = evaluate_wordpress_review(&[], &inputs).unwrap();
616        assert_eq!(result.local_input_provenance().len(), 3);
617        assert_eq!(
618            result.local_input_provenance()[0].class(),
619            WordPressLocalInputClass::PluginsJson
620        );
621        assert_eq!(result.local_input_provenance()[0].sha256(), &[1; 32]);
622        assert_eq!(result.inventory_summary(), inputs.inventory_summary());
623
624        let zeta = result
625            .components()
626            .iter()
627            .find(|component| component.identity().slug() == "zeta-plugin")
628            .unwrap();
629        assert_eq!(
630            zeta.evidence_class(),
631            WordPressComponentEvidenceClass::OperatorSupplied
632        );
633        assert_eq!(
634            zeta.identity_sources(),
635            &[WordPressEvidenceSource::OperatorContext]
636        );
637        assert_eq!(zeta.versions()[0].value(), "vendor-release-x");
638        assert_eq!(
639            zeta.inventory_status(),
640            Some(WordPressInventoryEntryStatus::Inactive)
641        );
642        assert_eq!(zeta.activation(), Some(WordPressActivationState::Inactive));
643
644        let network = result
645            .components()
646            .iter()
647            .find(|component| component.identity().slug() == "network-plugin")
648            .unwrap();
649        assert_eq!(
650            network.inventory_status(),
651            Some(WordPressInventoryEntryStatus::NetworkActive)
652        );
653        assert_eq!(
654            network.activation(),
655            Some(WordPressActivationState::NetworkActive)
656        );
657
658        let must_use = result
659            .components()
660            .iter()
661            .find(|component| component.identity().slug() == "must-use-plugin")
662            .unwrap();
663        assert!(must_use.versions().is_empty());
664        assert_eq!(
665            must_use.inventory_status(),
666            Some(WordPressInventoryEntryStatus::MustUse)
667        );
668        assert_eq!(must_use.activation(), None);
669
670        let parent = result
671            .components()
672            .iter()
673            .find(|component| component.identity().slug() == "parent-theme")
674            .unwrap();
675        assert_eq!(
676            parent.inventory_status(),
677            Some(WordPressInventoryEntryStatus::Parent)
678        );
679        assert_eq!(parent.activation(), None);
680        assert!(!result
681            .components()
682            .iter()
683            .any(|component| component.identity().slug() == "object-cache.php"));
684    }
685
686    #[test]
687    fn categories_not_selected_remain_explicitly_not_supplied() {
688        let inventory = parse_wordpress_saved_inventory(root(), Some(b"[]"), None, None).unwrap();
689        assert_eq!(
690            inventory.summary().coverage().plugins(),
691            WordPressInventoryCategoryStatus::Supplied
692        );
693        assert_eq!(
694            inventory.summary().coverage().themes(),
695            WordPressInventoryCategoryStatus::NotSupplied
696        );
697        assert_eq!(
698            inventory.summary().coverage().core(),
699            WordPressInventoryCategoryStatus::NotSupplied
700        );
701        assert_eq!(inventory.summary().component_count(), 0);
702    }
703
704    #[test]
705    fn strict_inventory_rows_reject_unknown_duplicate_or_inapplicable_fields() {
706        let unknown = br#"[{"name":"sample","status":"active","version":"1.0","path":"/tmp/x"}]"#;
707        assert_eq!(
708            parse_wordpress_saved_inventory(root(), Some(unknown), None, None),
709            Err(WordPressReviewError::InvalidInventory)
710        );
711
712        let duplicate_key =
713            br#"[{"name":"sample","name":"other","status":"active","version":"1.0"}]"#;
714        assert_eq!(
715            parse_wordpress_saved_inventory(root(), Some(duplicate_key), None, None),
716            Err(WordPressReviewError::DuplicateKey)
717        );
718
719        let plugin_parent = br#"[{"name":"sample","status":"parent","version":"1.0"}]"#;
720        assert_eq!(
721            parse_wordpress_saved_inventory(root(), Some(plugin_parent), None, None),
722            Err(WordPressReviewError::InvalidInventory)
723        );
724
725        let theme_network = br#"[{"name":"sample","status":"active-network","version":"1.0"}]"#;
726        assert_eq!(
727            parse_wordpress_saved_inventory(root(), None, Some(theme_network), None),
728            Err(WordPressReviewError::InvalidInventory)
729        );
730    }
731
732    #[test]
733    fn duplicate_component_and_drop_in_rows_fail_deterministically() {
734        let duplicate = br#"[
735          {"name":"sample","status":"active","version":"1.0"},
736          {"name":"sample","status":"inactive","version":"1.1"}
737        ]"#;
738        assert_eq!(
739            parse_wordpress_saved_inventory(root(), Some(duplicate), None, None),
740            Err(WordPressReviewError::DuplicateComponent)
741        );
742
743        let duplicate_drop_in = br#"[
744          {"name":"object-cache.php","status":"dropin","version":""},
745          {"name":"object-cache.php","status":"dropin","version":"1.0"}
746        ]"#;
747        assert_eq!(
748            parse_wordpress_saved_inventory(root(), Some(duplicate_drop_in), None, None),
749            Err(WordPressReviewError::DuplicateComponent)
750        );
751
752        let conflicting_classification = br#"[
753          {"name":"sample","status":"active","version":"1.0"},
754          {"name":"sample","status":"dropin","version":""}
755        ]"#;
756        assert_eq!(
757            parse_wordpress_saved_inventory(root(), Some(conflicting_classification), None, None),
758            Err(WordPressReviewError::DuplicateComponent)
759        );
760    }
761
762    #[test]
763    fn plugin_and_theme_with_same_slug_remain_distinct_component_identities() {
764        let plugin = br#"[{"name":"shared-name","status":"inactive","version":"1.0"}]"#;
765        let theme = br#"[{"name":"shared-name","status":"parent","version":"2.0"}]"#;
766        let inventory =
767            parse_wordpress_saved_inventory(root(), Some(plugin), Some(theme), None).unwrap();
768        assert_eq!(inventory.context.components().len(), 2);
769        assert_eq!(
770            inventory.context.components()[0].identity().kind(),
771            WordPressComponentKind::Plugin
772        );
773        assert_eq!(
774            inventory.context.components()[1].identity().kind(),
775            WordPressComponentKind::Theme
776        );
777    }
778
779    #[test]
780    fn inventory_versions_reject_console_whitespace_but_keep_unsupported_labels() {
781        for version in [" 1.0", "1.0 ", "1.0 beta"] {
782            let document =
783                format!(r#"[{{"name":"sample","status":"active","version":"{version}"}}]"#);
784            assert_eq!(
785                parse_wordpress_saved_inventory(root(), Some(document.as_bytes()), None, None),
786                Err(WordPressReviewError::InvalidInventory)
787            );
788        }
789        let unsupported = br#"[{"name":"sample","status":"active","version":"vendor-build-x"}]"#;
790        let inventory =
791            parse_wordpress_saved_inventory(root(), Some(unsupported), None, None).unwrap();
792        assert_eq!(
793            inventory.context.components()[0].version(),
794            Some("vendor-build-x")
795        );
796
797        let empty_active = br#"[{"name":"sample","status":"active","version":""}]"#;
798        assert_eq!(
799            parse_wordpress_saved_inventory(root(), Some(empty_active), None, None),
800            Err(WordPressReviewError::InvalidInventory)
801        );
802    }
803
804    #[test]
805    fn discarded_default_fields_are_scalar_and_independently_bounded() {
806        let ordinary_scalars = br#"[{
807          "name":"sample","status":"active","version":"1.0",
808          "update":false,"update_version":null,"auto_update":true
809        }]"#;
810        let inventory =
811            parse_wordpress_saved_inventory(root(), Some(ordinary_scalars), None, None).unwrap();
812        assert_eq!(inventory.context.components().len(), 1);
813        assert_eq!(
814            inventory.context.components()[0].identity().slug(),
815            "sample"
816        );
817        assert_eq!(inventory.context.components()[0].version(), Some("1.0"));
818        assert_eq!(
819            inventory.context.components()[0].inventory_status(),
820            Some(WordPressInventoryEntryStatus::Active)
821        );
822        assert!(inventory.context.components()[0].patches().is_empty());
823
824        let oversized = "x".repeat(MAX_WORDPRESS_INVENTORY_INFORMATIONAL_BYTES + 1);
825        let document = format!(
826            r#"[{{"name":"sample","status":"active","version":"1.0","update":"{oversized}"}}]"#
827        );
828        assert_eq!(
829            parse_wordpress_saved_inventory(root(), Some(document.as_bytes()), None, None),
830            Err(WordPressReviewError::InvalidInventory)
831        );
832
833        let control =
834            b"[{\"name\":\"sample\",\"status\":\"active\",\"version\":\"1.0\",\"auto_update\":\"on\\nnoise\"}]";
835        assert_eq!(
836            parse_wordpress_saved_inventory(root(), Some(control), None, None),
837            Err(WordPressReviewError::InvalidInventory)
838        );
839
840        let nested =
841            br#"[{"name":"sample","status":"active","version":"1.0","update":{"state":"none"}}]"#;
842        assert_eq!(
843            parse_wordpress_saved_inventory(root(), Some(nested), None, None),
844            Err(WordPressReviewError::InvalidInventory)
845        );
846
847        let array =
848            br#"[{"name":"sample","status":"active","version":"1.0","update_version":["2.0"]}]"#;
849        assert_eq!(
850            parse_wordpress_saved_inventory(root(), Some(array), None, None),
851            Err(WordPressReviewError::InvalidInventory)
852        );
853
854        let number = br#"[{"name":"sample","status":"active","version":"1.0","auto_update":1}]"#;
855        assert_eq!(
856            parse_wordpress_saved_inventory(root(), Some(number), None, None),
857            Err(WordPressReviewError::InvalidInventory)
858        );
859    }
860
861    #[test]
862    fn core_version_accepts_only_one_exact_line_with_optional_final_newline() {
863        for bytes in [
864            b"6.9.4-beta1".as_slice(),
865            b"6.9.4-beta1\n".as_slice(),
866            b"6.9.4-beta1\r\n".as_slice(),
867        ] {
868            let inventory =
869                parse_wordpress_saved_inventory(root(), None, None, Some(bytes)).unwrap();
870            assert_eq!(
871                inventory.context.components()[0].version(),
872                Some("6.9.4-beta1")
873            );
874        }
875        for bytes in [
876            b" 6.9.4".as_slice(),
877            b"6.9.4 ".as_slice(),
878            b"6.9.4\nnoise".as_slice(),
879            b"6.9.4\n\n".as_slice(),
880            b"".as_slice(),
881        ] {
882            assert_eq!(
883                parse_wordpress_saved_inventory(root(), None, None, Some(bytes)),
884                Err(WordPressReviewError::InvalidInventory)
885            );
886        }
887    }
888
889    #[test]
890    fn provenance_must_match_every_supplied_input_class_and_exact_length() {
891        let bytes = b"[]";
892        let inventory = parse_wordpress_saved_inventory(root(), Some(bytes), None, None).unwrap();
893        assert_eq!(
894            WordPressReviewInputs::from_saved_inventory(inventory.clone(), None, Vec::new()),
895            Err(WordPressReviewError::InvalidInventoryProvenance)
896        );
897        assert_eq!(
898            WordPressReviewInputs::from_saved_inventory(
899                inventory.clone(),
900                None,
901                vec![WordPressLocalInputProvenance::new(
902                    WordPressLocalInputClass::PluginsJson,
903                    bytes.len() + 1,
904                    [1; 32],
905                )],
906            ),
907            Err(WordPressReviewError::InvalidInventoryProvenance)
908        );
909        assert_eq!(
910            WordPressReviewInputs::from_saved_inventory(
911                inventory.clone(),
912                None,
913                vec![WordPressLocalInputProvenance::new(
914                    WordPressLocalInputClass::ThemesJson,
915                    bytes.len(),
916                    [1; 32],
917                )],
918            ),
919            Err(WordPressReviewError::InvalidInventoryProvenance)
920        );
921        assert_eq!(
922            WordPressReviewInputs::from_saved_inventory(
923                inventory,
924                None,
925                vec![
926                    WordPressLocalInputProvenance::new(
927                        WordPressLocalInputClass::PluginsJson,
928                        bytes.len(),
929                        [1; 32],
930                    ),
931                    WordPressLocalInputProvenance::new(
932                        WordPressLocalInputClass::PluginsJson,
933                        bytes.len(),
934                        [2; 32],
935                    ),
936                ],
937            ),
938            Err(WordPressReviewError::InvalidInventoryProvenance)
939        );
940    }
941
942    #[test]
943    fn aggregate_byte_and_component_limits_are_enforced_before_evaluation() {
944        let oversized = vec![b' '; MAX_WORDPRESS_SAVED_INVENTORY_BYTES + 1];
945        assert_eq!(
946            parse_wordpress_saved_inventory(root(), Some(&oversized), None, None),
947            Err(WordPressReviewError::InventoryTooLarge)
948        );
949
950        let rows = (0..MAX_WORDPRESS_CONTEXT_COMPONENTS)
951            .map(|index| {
952                format!(r#"{{"name":"plugin-{index}","status":"active","version":"1.0"}}"#)
953            })
954            .collect::<Vec<_>>()
955            .join(",");
956        let document = format!("[{rows}]");
957        assert_eq!(
958            parse_wordpress_saved_inventory(
959                root(),
960                Some(document.as_bytes()),
961                None,
962                Some(b"6.9.4"),
963            ),
964            Err(WordPressReviewError::InventoryComponentLimitExceeded)
965        );
966    }
967
968    #[test]
969    fn inventory_requires_at_least_one_explicit_input() {
970        assert_eq!(
971            parse_wordpress_saved_inventory(root(), None, None, None),
972            Err(WordPressReviewError::EmptyInput)
973        );
974    }
975
976    #[test]
977    fn existing_context_inputs_do_not_acquire_saved_inventory_metadata() {
978        let context = parse_wordpress_context(
979            br#"{
980              "schema":"security.wordpress-context/v1",
981              "root":"https://example.test/",
982              "components":[
983                {"kind":"plugin","slug":"sample","version":"1.0","activation":"active"}
984              ]
985            }"#,
986        )
987        .unwrap();
988        assert_eq!(context.components()[0].inventory_status(), None);
989
990        let result =
991            evaluate_wordpress_review(&[], &WordPressReviewInputs::new(Some(context), None))
992                .unwrap();
993        assert_eq!(result.inventory_summary(), None);
994        assert!(result.local_input_provenance().is_empty());
995        assert_eq!(result.components()[0].inventory_status(), None);
996    }
997}