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
14pub 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
197pub 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 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}