1use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use fallow_config::{
21 ExternalPluginDef, ManifestCondition, ManifestEntryRule, ManifestFieldPath,
22 ManifestFieldSegment, ManifestFormat, ManifestPathPart, ManifestPathTemplate,
23};
24use serde_json::Value;
25
26use super::PathRule;
27use super::config_parser::normalize_config_path;
28
29const MAX_MANIFEST_FIELD_VALUES: usize = 1_024;
33
34const MAX_MANIFEST_ENTRY_EXPANSIONS: usize = 4_096;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum WarningKind {
43 ManifestsMatchedNone,
45 WhenExcludedAll,
47 FieldPathUnresolved,
49 EntriesEmpty,
51 ManifestParseFailed,
53 FieldValuesLimitExceeded,
55 EntryExpansionLimitExceeded,
57 EntryOutsideRoot,
59 SeededPathsMissing,
62}
63
64impl WarningKind {
65 #[must_use]
67 pub fn as_kebab(self) -> &'static str {
68 match self {
69 Self::ManifestsMatchedNone => "manifests-matched-none",
70 Self::WhenExcludedAll => "when-excluded-all",
71 Self::FieldPathUnresolved => "field-path-unresolved",
72 Self::EntriesEmpty => "entries-empty",
73 Self::ManifestParseFailed => "manifest-parse-failed",
74 Self::FieldValuesLimitExceeded => "field-values-limit-exceeded",
75 Self::EntryExpansionLimitExceeded => "entry-expansion-limit-exceeded",
76 Self::EntryOutsideRoot => "entry-outside-root",
77 Self::SeededPathsMissing => "seeded-paths-missing",
78 }
79 }
80
81 #[must_use]
83 pub fn expansion_limit(self) -> Option<usize> {
84 match self {
85 Self::FieldValuesLimitExceeded => Some(MAX_MANIFEST_FIELD_VALUES),
86 Self::EntryExpansionLimitExceeded => Some(MAX_MANIFEST_ENTRY_EXPANSIONS),
87 _ => None,
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct CheckWarning {
96 pub kind: WarningKind,
97 pub glob: Option<String>,
99 pub field_path: Option<String>,
101 pub manifest: Option<String>,
103 pub entry: Option<String>,
105}
106
107impl CheckWarning {
108 fn glob(kind: WarningKind, glob: &str) -> Self {
110 Self {
111 kind,
112 glob: Some(glob.to_string()),
113 field_path: None,
114 manifest: None,
115 entry: None,
116 }
117 }
118
119 fn field(kind: WarningKind, field_path: String) -> Self {
121 Self {
122 kind,
123 glob: None,
124 field_path: Some(field_path),
125 manifest: None,
126 entry: None,
127 }
128 }
129
130 fn manifest(kind: WarningKind, manifest: String) -> Self {
132 Self {
133 kind,
134 glob: None,
135 field_path: None,
136 manifest: Some(manifest),
137 entry: None,
138 }
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143enum ExpansionError {
144 FieldValues { field_path: String },
145 EntryPaths { template: String },
146}
147
148impl ExpansionError {
149 fn into_warning(self, manifest: Option<String>) -> CheckWarning {
150 match self {
151 Self::FieldValues { field_path } => CheckWarning {
152 kind: WarningKind::FieldValuesLimitExceeded,
153 glob: None,
154 field_path: Some(field_path),
155 manifest,
156 entry: None,
157 },
158 Self::EntryPaths { template } => CheckWarning {
159 kind: WarningKind::EntryExpansionLimitExceeded,
160 glob: None,
161 field_path: None,
162 manifest,
163 entry: Some(template),
164 },
165 }
166 }
167}
168
169#[derive(Debug, Clone)]
171pub struct ManifestResult {
172 pub path: String,
174 pub when_passed: bool,
176 pub seeded: Vec<String>,
179}
180
181#[derive(Debug, Clone)]
185pub struct RuleReport {
186 pub manifests: String,
188 pub manifests_matched: Vec<String>,
190 pub matched: Vec<ManifestResult>,
192 pub warnings: Vec<CheckWarning>,
195}
196
197#[must_use]
208pub(crate) fn evaluate_manifest_entries(ext: &ExternalPluginDef, root: &Path) -> Vec<PathRule> {
209 let mut out = Vec::new();
210 for rule in &ext.manifest_entries {
211 let report = build_rule_report(rule, root);
212 for manifest in &report.matched {
213 for seed in &manifest.seeded {
214 out.push(PathRule::new(seed.clone()));
215 }
216 }
217 emit_report_warnings(&ext.name, &report);
218 }
219 out
220}
221
222#[must_use]
226pub fn check_manifest_entries(ext: &ExternalPluginDef, root: &Path) -> Vec<RuleReport> {
227 ext.manifest_entries
228 .iter()
229 .map(|rule| build_rule_report(rule, root))
230 .collect()
231}
232
233fn build_rule_report(rule: &ManifestEntryRule, root: &Path) -> RuleReport {
236 let mut report = RuleReport {
237 manifests: rule.manifests.clone(),
238 manifests_matched: Vec::new(),
239 matched: Vec::new(),
240 warnings: Vec::new(),
241 };
242
243 if rule.entries.is_empty() {
244 report.warnings.push(CheckWarning::glob(
245 WarningKind::EntriesEmpty,
246 &rule.manifests,
247 ));
248 return report;
249 }
250
251 let Ok(glob) = globset::Glob::new(&rule.manifests) else {
252 report.warnings.push(CheckWarning::glob(
255 WarningKind::ManifestsMatchedNone,
256 &rule.manifests,
257 ));
258 return report;
259 };
260 let matcher = glob.compile_matcher();
261
262 let referenced = referenced_field_paths(rule);
263 let mut resolved: BTreeMap<&str, bool> =
264 referenced.iter().map(|p| (p.as_str(), false)).collect();
265 let mut passed = 0usize;
266 let mut parsed = 0usize;
267 let mut gate_errors = 0usize;
268
269 for file in discover_manifest_paths(root, &matcher) {
270 let rel_manifest = root_relative_forward_slash(&file, root)
271 .unwrap_or_else(|| file.to_string_lossy().replace('\\', "/"));
272 report.manifests_matched.push(rel_manifest.clone());
273
274 let manifest: Value = match std::fs::read_to_string(&file)
275 .ok()
276 .and_then(|source| parse_manifest(&source, rule.format))
277 {
278 Some(value) => value,
279 None => {
280 report.warnings.push(CheckWarning::manifest(
283 WarningKind::ManifestParseFailed,
284 rel_manifest,
285 ));
286 continue;
287 }
288 };
289 parsed += 1;
290
291 let when_passed = match when_matches(&manifest, &rule.when) {
292 Ok(passed) => passed,
293 Err(error) => {
294 gate_errors += 1;
295 report
296 .warnings
297 .push(error.into_warning(Some(rel_manifest.clone())));
298 false
299 }
300 };
301 let mut seeded = Vec::new();
302 if when_passed {
303 passed += 1;
304 for path in &referenced {
305 let path_resolved = match field_values(&manifest, path) {
306 Ok(values) => !values.is_empty(),
307 Err(_) => true,
310 };
311 if path_resolved && let Some(flag) = resolved.get_mut(path.as_str()) {
312 *flag = true;
313 }
314 }
315 let (entries, mut entry_warnings) = seed_rule_entries(rule, &manifest, &file, root);
316 seeded = entries;
317 report.warnings.append(&mut entry_warnings);
318 }
319 report.matched.push(ManifestResult {
320 path: rel_manifest,
321 when_passed,
322 seeded,
323 });
324 }
325
326 report.warnings.extend(rule_level_warnings(
327 &rule.manifests,
328 report.manifests_matched.len(),
329 parsed,
330 passed,
331 gate_errors,
332 &resolved,
333 ));
334
335 report.matched.sort_by(|a, b| a.path.cmp(&b.path));
340 report.warnings.sort_by(|a, b| {
341 a.kind
342 .as_kebab()
343 .cmp(b.kind.as_kebab())
344 .then_with(|| a.manifest.cmp(&b.manifest))
345 .then_with(|| a.entry.cmp(&b.entry))
346 .then_with(|| a.field_path.cmp(&b.field_path))
347 });
348 report.warnings.dedup();
349 report
350}
351
352#[must_use]
358pub(crate) fn seed_parsed_manifest(
359 rule: &ManifestEntryRule,
360 manifest: &Value,
361 manifest_path: &Path,
362 root: &Path,
363) -> Vec<String> {
364 if !matches!(when_matches(manifest, &rule.when), Ok(true)) {
365 return Vec::new();
366 }
367 seed_rule_entries(rule, manifest, manifest_path, root).0
368}
369
370#[must_use]
372pub(crate) fn parse_manifest(source: &str, format: ManifestFormat) -> Option<Value> {
373 match format {
374 ManifestFormat::Jsonc => fallow_config::jsonc::parse_to_value(source).ok(),
375 ManifestFormat::Json => serde_json::from_str(source).ok(),
376 }
377}
378
379fn rule_level_warnings(
386 manifests: &str,
387 matched: usize,
388 parsed: usize,
389 passed: usize,
390 gate_errors: usize,
391 resolved: &BTreeMap<&str, bool>,
392) -> Vec<CheckWarning> {
393 let mut out = Vec::new();
394 if matched == 0 {
395 out.push(CheckWarning::glob(
396 WarningKind::ManifestsMatchedNone,
397 manifests,
398 ));
399 return out;
400 }
401 if parsed > 0 && passed == 0 && gate_errors == 0 {
405 out.push(CheckWarning::glob(WarningKind::WhenExcludedAll, manifests));
406 return out;
407 }
408 if passed == 0 {
409 return out;
410 }
411 for (path, was_resolved) in resolved {
412 if !was_resolved {
413 out.push(CheckWarning::field(
414 WarningKind::FieldPathUnresolved,
415 (*path).to_string(),
416 ));
417 }
418 }
419 out
420}
421
422fn seed_rule_entries(
425 rule: &ManifestEntryRule,
426 manifest: &Value,
427 manifest_path: &Path,
428 root: &Path,
429) -> (Vec<String>, Vec<CheckWarning>) {
430 let rel_manifest = root_relative_forward_slash(manifest_path, root);
431 let mut seeded = Vec::new();
432 let mut warnings = Vec::new();
433 for seed in &rule.entries {
434 match when_matches(manifest, &seed.when) {
435 Ok(true) => {}
436 Ok(false) => continue,
437 Err(error) => {
438 warnings.push(error.into_warning(rel_manifest.clone()));
439 continue;
440 }
441 }
442 let concretes = match expand_interpolations(&seed.path, manifest) {
443 Ok(concretes) => concretes,
444 Err(error) => {
445 warnings.push(error.into_warning(rel_manifest.clone()));
446 continue;
447 }
448 };
449 for concrete in concretes {
450 match normalize_config_path(&concrete, manifest_path, root) {
451 Some(rel) => seeded.push(rel),
452 None => warnings.push(CheckWarning {
453 kind: WarningKind::EntryOutsideRoot,
454 glob: None,
455 field_path: None,
456 manifest: rel_manifest.clone(),
457 entry: Some(concrete),
458 }),
459 }
460 }
461 }
462 (seeded, warnings)
463}
464
465fn emit_report_warnings(plugin_name: &str, report: &RuleReport) {
467 for warning in &report.warnings {
468 match warning.kind {
469 WarningKind::EntriesEmpty => tracing::warn!(
470 "Plugin '{plugin_name}': manifestEntries rule for '{}' has an empty 'entries' \
471 list; it seeds nothing.",
472 report.manifests
473 ),
474 WarningKind::ManifestsMatchedNone => tracing::warn!(
475 "Plugin '{plugin_name}': manifestEntries 'manifests' glob '{}' matched no files. \
476 Check the glob and whether the manifests live under an ignored directory.",
477 report.manifests
478 ),
479 WarningKind::ManifestParseFailed => tracing::warn!(
480 "Plugin '{plugin_name}': manifestEntries skipped manifest '{}' (glob '{}') because \
481 it could not be read or parsed using the rule's declared format.",
482 warning.manifest.as_deref().unwrap_or(""),
483 report.manifests
484 ),
485 WarningKind::FieldValuesLimitExceeded => tracing::warn!(
486 "Plugin '{plugin_name}': manifestEntries field path '{}' in manifest '{}' exceeded \
487 the traversal value limit of {}. The affected gate or template was skipped without \
488 partial seeding.",
489 warning.field_path.as_deref().unwrap_or(""),
490 warning.manifest.as_deref().unwrap_or(""),
491 warning
492 .kind
493 .expansion_limit()
494 .unwrap_or(MAX_MANIFEST_FIELD_VALUES)
495 ),
496 WarningKind::EntryExpansionLimitExceeded => tracing::warn!(
497 "Plugin '{plugin_name}': manifestEntries template '{}' in manifest '{}' exceeded \
498 the concrete entry limit of {}. No entries were seeded from that template.",
499 warning.entry.as_deref().unwrap_or(""),
500 warning.manifest.as_deref().unwrap_or(""),
501 warning
502 .kind
503 .expansion_limit()
504 .unwrap_or(MAX_MANIFEST_ENTRY_EXPANSIONS)
505 ),
506 WarningKind::WhenExcludedAll => tracing::warn!(
507 "Plugin '{plugin_name}': manifestEntries 'when' gate excluded all matched \
508 manifest(s) for glob '{}'. No entries were seeded.",
509 report.manifests
510 ),
511 WarningKind::FieldPathUnresolved => tracing::warn!(
512 "Plugin '{plugin_name}': manifestEntries field path '{}' resolved in none of the \
513 gated manifest(s). Likely a typo in a 'when' key or a ${{...}} interpolation.",
514 warning.field_path.as_deref().unwrap_or("")
515 ),
516 WarningKind::EntryOutsideRoot => tracing::warn!(
517 "Plugin '{plugin_name}': manifestEntries entry '{}' (from manifest '{}') resolved \
518 outside the project root and was skipped.",
519 warning.entry.as_deref().unwrap_or(""),
520 warning.manifest.as_deref().unwrap_or("")
521 ),
522 WarningKind::SeededPathsMissing => {}
524 }
525 }
526}
527
528fn referenced_field_paths(rule: &ManifestEntryRule) -> Vec<ManifestFieldPath> {
531 let mut paths: Vec<ManifestFieldPath> = rule
532 .when
533 .iter()
534 .filter(|(_, condition)| condition_requires_present_value(condition))
535 .map(|(path, _)| path.clone())
536 .collect();
537 for seed in &rule.entries {
538 paths.extend(
539 seed.when
540 .iter()
541 .filter(|(_, condition)| condition_requires_present_value(condition))
542 .map(|(path, _)| path.clone()),
543 );
544 paths.extend(seed.path.parts().iter().filter_map(|part| match part {
545 ManifestPathPart::Field(path) => Some(path.clone()),
546 ManifestPathPart::Literal(_) => None,
547 }));
548 }
549 paths.sort();
550 paths.dedup();
551 paths
552}
553
554fn expand_interpolations(
558 path: &ManifestPathTemplate,
559 manifest: &Value,
560) -> Result<Vec<String>, ExpansionError> {
561 let mut expanded = vec![String::new()];
562 for part in path.parts() {
563 match part {
564 ManifestPathPart::Literal(literal) => {
565 for value in &mut expanded {
566 value.push_str(literal);
567 }
568 }
569 ManifestPathPart::Field(field) => {
570 let values = field_segment_values(manifest, field)?;
571 if values.is_empty() {
572 return Ok(Vec::new());
573 }
574
575 let Some(next_len) = expanded.len().checked_mul(values.len()) else {
576 return Err(ExpansionError::EntryPaths {
577 template: path.as_str().to_string(),
578 });
579 };
580 if next_len > MAX_MANIFEST_ENTRY_EXPANSIONS {
581 return Err(ExpansionError::EntryPaths {
582 template: path.as_str().to_string(),
583 });
584 }
585
586 let mut next = Vec::with_capacity(next_len);
587 for prefix in &expanded {
588 for value in &values {
589 let mut concrete = String::with_capacity(prefix.len() + value.len());
590 concrete.push_str(prefix);
591 concrete.push_str(value);
592 next.push(concrete);
593 }
594 }
595 expanded = next;
596 }
597 }
598 }
599 Ok(expanded)
600}
601
602fn field_segment_values(
605 manifest: &Value,
606 field: &ManifestFieldPath,
607) -> Result<Vec<String>, ExpansionError> {
608 let mut values = Vec::new();
609 for value in field_values(manifest, field)? {
610 match value {
611 Value::Array(items) => {
612 for item in items.iter().filter_map(scalar_segment) {
613 push_field_segment(&mut values, item, field)?;
614 }
615 }
616 value => {
617 if let Some(segment) = scalar_segment(value) {
618 push_field_segment(&mut values, segment, field)?;
619 }
620 }
621 }
622 }
623 Ok(values)
624}
625
626fn push_field_segment(
627 values: &mut Vec<String>,
628 value: String,
629 field: &ManifestFieldPath,
630) -> Result<(), ExpansionError> {
631 if values.len() == MAX_MANIFEST_FIELD_VALUES {
632 return Err(ExpansionError::FieldValues {
633 field_path: field.as_str().to_string(),
634 });
635 }
636 values.push(value);
637 Ok(())
638}
639
640fn scalar_segment(value: &Value) -> Option<String> {
641 match value {
642 Value::String(s) if !s.is_empty() => Some(s.clone()),
643 Value::Number(n) => Some(n.to_string()),
644 _ => None,
645 }
646}
647
648fn when_matches(
651 manifest: &Value,
652 when: &BTreeMap<ManifestFieldPath, ManifestCondition>,
653) -> Result<bool, ExpansionError> {
654 for (path, condition) in when {
655 let values = field_values(manifest, path)?;
656 let matches = match condition {
657 ManifestCondition::Equals(expected) => values.contains(&expected),
658 ManifestCondition::Exists(predicate) => values.is_empty() != predicate.exists,
659 };
660 if !matches {
661 return Ok(false);
662 }
663 }
664 Ok(true)
665}
666
667fn condition_requires_present_value(condition: &ManifestCondition) -> bool {
668 match condition {
669 ManifestCondition::Equals(_) => true,
670 ManifestCondition::Exists(predicate) => predicate.exists,
671 }
672}
673
674fn field_values<'a>(
676 value: &'a Value,
677 path: &ManifestFieldPath,
678) -> Result<Vec<&'a Value>, ExpansionError> {
679 let mut current = vec![value];
680 for segment in path.segments() {
681 let mut next = Vec::new();
682 for value in current {
683 match segment {
684 ManifestFieldSegment::Key(key) => {
685 if let Some(child) = value.get(key) {
686 push_field_value(&mut next, child, path)?;
687 }
688 }
689 ManifestFieldSegment::Each => {
690 if let Value::Array(items) = value {
691 for item in items {
692 push_field_value(&mut next, item, path)?;
693 }
694 }
695 }
696 }
697 }
698 current = next;
699 }
700 Ok(current)
701}
702
703fn push_field_value<'a>(
704 values: &mut Vec<&'a Value>,
705 value: &'a Value,
706 path: &ManifestFieldPath,
707) -> Result<(), ExpansionError> {
708 if values.len() == MAX_MANIFEST_FIELD_VALUES {
709 return Err(ExpansionError::FieldValues {
710 field_path: path.as_str().to_string(),
711 });
712 }
713 values.push(value);
714 Ok(())
715}
716
717fn discover_manifest_paths(root: &Path, matcher: &globset::GlobMatcher) -> Vec<PathBuf> {
721 let mut out = Vec::new();
722 let canonical_root = root.canonicalize().ok();
723 let walker = fallow_config::source_walk_builder(root)
724 .filter_entry(|entry| entry.file_name() != "node_modules")
725 .build();
726 for entry in walker.flatten() {
727 let Some(file_type) = entry.file_type() else {
728 continue;
729 };
730 if file_type.is_dir() {
731 continue;
732 }
733 let path = entry.path();
734 if file_type.is_symlink()
735 && !is_contained_regular_file_symlink(path, canonical_root.as_deref())
736 {
737 tracing::debug!(
738 path = %path.display(),
739 "skipping manifest symlink with a broken, non-file, or outside-root target"
740 );
741 continue;
742 }
743 if let Some(rel) = root_relative_forward_slash(path, root)
744 && matcher.is_match(Path::new(&rel))
745 {
746 out.push(path.to_path_buf());
747 }
748 }
749 out.sort();
753 out
754}
755
756fn is_contained_regular_file_symlink(path: &Path, canonical_root: Option<&Path>) -> bool {
757 let Some(root) = canonical_root else {
758 return false;
759 };
760 let Ok(target) = path.canonicalize() else {
761 return false;
762 };
763 target.starts_with(root) && target.metadata().is_ok_and(|metadata| metadata.is_file())
764}
765
766fn root_relative_forward_slash(file: &Path, root: &Path) -> Option<String> {
769 let rel = file.strip_prefix(root).ok()?;
770 Some(rel.to_string_lossy().replace('\\', "/"))
771}
772
773#[cfg(test)]
774mod tests {
775 use super::*;
776 use fallow_config::{
777 EntryPointRole, ManifestExistsPredicate, ManifestFormat, ManifestSeedRule,
778 };
779
780 fn json(text: &str) -> Value {
781 serde_json::from_str(text).unwrap()
782 }
783
784 fn seed(path: &str, when: &[(&str, Value)]) -> ManifestSeedRule {
785 ManifestSeedRule {
786 path: path.parse().unwrap(),
787 when: conditions(when),
788 }
789 }
790
791 fn field(path: &str) -> ManifestFieldPath {
792 path.parse().unwrap()
793 }
794
795 fn template(path: &str) -> ManifestPathTemplate {
796 path.parse().unwrap()
797 }
798
799 fn conditions(when: &[(&str, Value)]) -> BTreeMap<ManifestFieldPath, ManifestCondition> {
800 when.iter()
801 .map(|(path, expected)| (field(path), ManifestCondition::Equals(expected.clone())))
802 .collect()
803 }
804
805 fn exists(path: &str, expected: bool) -> (ManifestFieldPath, ManifestCondition) {
806 (
807 field(path),
808 ManifestCondition::Exists(ManifestExistsPredicate { exists: expected }),
809 )
810 }
811
812 #[test]
813 fn field_values_traverse_nested_fields_and_object_arrays() {
814 let m = json(r#"{"plugin": {"browser": true, "id": "actions"}}"#);
815 assert_eq!(
816 field_values(&m, &field("plugin.browser")).unwrap(),
817 vec![&Value::Bool(true)]
818 );
819 assert_eq!(
820 field_values(&m, &field("plugin.id")).unwrap(),
821 vec![&Value::String("actions".into())]
822 );
823 assert!(
824 field_values(&m, &field("plugin.missing"))
825 .unwrap()
826 .is_empty()
827 );
828 assert!(field_values(&m, &field("absent.field")).unwrap().is_empty());
829
830 let m = json(
831 r#"{"content_scripts":[{"js":["a.js","b.js"]},null,{"js":["c.js"]},"invalid",{"css":[]}]}"#,
832 );
833 assert_eq!(
834 field_segment_values(&m, &field("content_scripts[*].js")).unwrap(),
835 vec!["a.js", "b.js", "c.js"]
836 );
837
838 let nested = json(
839 r#"{"groups":[{"entries":[{"path":"a.js"},{"path":"b.js"}]},{"entries":[{"path":"c.js"}]}]}"#,
840 );
841 assert_eq!(
842 field_segment_values(&nested, &field("groups[*].entries[*].path")).unwrap(),
843 vec!["a.js", "b.js", "c.js"]
844 );
845 }
846
847 #[test]
848 fn when_matches_is_strict_equality_and_presence_is_not_matched() {
849 let m = json(r#"{"type": "plugin", "plugin": {"browser": false}}"#);
850 let mut when = BTreeMap::new();
851 when.insert(
852 field("type"),
853 ManifestCondition::Equals(Value::String("plugin".into())),
854 );
855 assert!(when_matches(&m, &when).unwrap());
856
857 let mut when_browser = BTreeMap::new();
860 when_browser.insert(
861 field("plugin.browser"),
862 ManifestCondition::Equals(Value::Bool(true)),
863 );
864 assert!(!when_matches(&m, &when_browser).unwrap());
865
866 assert!(when_matches(&m, &BTreeMap::new()).unwrap());
868
869 let manifest = json(r#"{"plugins":[{"kind":"worker"},{"kind":"browser"}]}"#);
870 let wildcard = conditions(&[("plugins[*].kind", Value::String("browser".into()))]);
871 assert!(when_matches(&manifest, &wildcard).unwrap());
872
873 let structured = json(r#"{"array":["worker"],"object":{"kind":"browser"}}"#);
874 let structured_when = BTreeMap::from([
875 (
876 field("array"),
877 ManifestCondition::Equals(json(r#"["worker"]"#)),
878 ),
879 (
880 field("object"),
881 ManifestCondition::Equals(json(r#"{"kind":"browser"}"#)),
882 ),
883 ]);
884 assert!(when_matches(&structured, &structured_when).unwrap());
885 }
886
887 #[test]
888 fn exists_conditions_test_presence_without_truthiness() {
889 let manifest = json(
890 r#"{"presentFalse":false,"presentNull":null,"presentEmpty":"","presentObject":{},"items":[]}"#,
891 );
892 for path in [
893 "presentFalse",
894 "presentNull",
895 "presentEmpty",
896 "presentObject",
897 "items",
898 ] {
899 assert!(
900 when_matches(&manifest, &BTreeMap::from([exists(path, true)])).unwrap(),
901 "{path} is present regardless of its value"
902 );
903 }
904 assert!(when_matches(&manifest, &BTreeMap::from([exists("missing", false)])).unwrap());
905 assert!(!when_matches(&manifest, &BTreeMap::from([exists("missing", true)])).unwrap());
906 assert!(
907 when_matches(
908 &manifest,
909 &BTreeMap::from([exists("items[*].value", false)])
910 )
911 .unwrap()
912 );
913 }
914
915 #[test]
916 fn exists_false_can_gate_a_rule_without_an_unresolved_path_warning() {
917 let dir = tempfile::tempdir().unwrap();
918 let root = dir.path();
919 write_manifest(root, "plugins/alpha/manifest.json", r#"{"name":"alpha"}"#);
920 let mut manifest_rule = rule("**/manifest.json", &[], vec![seed("index.ts", &[])]);
921 manifest_rule.when = BTreeMap::from([exists("main", false)]);
922
923 let reports = check_manifest_entries(&plugin_with(vec![manifest_rule]), root);
924 assert!(reports[0].warnings.is_empty());
925 assert!(reports[0].matched[0].when_passed);
926 assert_eq!(reports[0].matched[0].seeded, vec!["plugins/alpha/index.ts"]);
927 }
928
929 #[test]
930 fn parse_manifest_honors_the_declared_format() {
931 let jsonc_only = r#"{
932 // JSONC comment
933 "type": "plugin",
934 }"#;
935
936 assert!(parse_manifest(jsonc_only, ManifestFormat::Jsonc).is_some());
937 assert!(parse_manifest(jsonc_only, ManifestFormat::Json).is_none());
938 assert!(parse_manifest(r#"{"type":"plugin"}"#, ManifestFormat::Json).is_some());
939 }
940
941 #[test]
942 fn expand_interpolations_string_array_and_missing() {
943 let m = json(r#"{"plugin": {"extraPublicDirs": ["common", "types"], "id": "actions"}}"#);
944 assert_eq!(
946 expand_interpolations(&template("${plugin.id}/index.ts"), &m).unwrap(),
947 vec!["actions/index.ts"]
948 );
949 assert_eq!(
951 expand_interpolations(&template("${plugin.extraPublicDirs}/index.{ts,tsx}"), &m)
952 .unwrap(),
953 vec!["common/index.{ts,tsx}", "types/index.{ts,tsx}"]
954 );
955 assert!(
957 expand_interpolations(&template("${plugin.absent}/index.ts"), &m)
958 .unwrap()
959 .is_empty()
960 );
961 assert_eq!(
963 expand_interpolations(&template("public/index.{ts,tsx}"), &m).unwrap(),
964 vec!["public/index.{ts,tsx}"]
965 );
966 }
967
968 #[test]
969 fn interpolation_limits_are_explicit_errors() {
970 let too_many: Vec<Value> = (0..=MAX_MANIFEST_FIELD_VALUES)
971 .map(|index| Value::String(index.to_string()))
972 .collect();
973 let manifest = serde_json::json!({ "values": too_many });
974 assert_eq!(
975 expand_interpolations(&template("${values}/index.ts"), &manifest),
976 Err(ExpansionError::FieldValues {
977 field_path: "values".to_string(),
978 })
979 );
980
981 let factors = (0..65)
982 .map(|index| Value::String(index.to_string()))
983 .collect::<Vec<_>>();
984 let manifest = serde_json::json!({ "left": factors, "right": factors });
985 assert_eq!(
986 expand_interpolations(&template("${left}/${right}/index.ts"), &manifest),
987 Err(ExpansionError::EntryPaths {
988 template: "${left}/${right}/index.ts".to_string(),
989 })
990 );
991 }
992
993 #[test]
994 fn evaluate_seeds_relative_to_manifest_dir_with_when_and_fanout() {
995 let dir = tempfile::tempdir().unwrap();
996 let root = dir.path();
997 let manifest_dir = root.join("x-pack/plugins/actions");
998 std::fs::create_dir_all(&manifest_dir).unwrap();
999 let manifest_path = manifest_dir.join("kibana.jsonc");
1000 std::fs::write(
1001 &manifest_path,
1002 r#"{
1003 // a real Kibana-shaped manifest
1004 "type": "plugin",
1005 "plugin": { "browser": true, "server": false, "extraPublicDirs": ["common"] },
1006 }"#,
1007 )
1008 .unwrap();
1009
1010 let ext = ExternalPluginDef {
1011 schema: None,
1012 name: "kibana".to_string(),
1013 detection: None,
1014 enablers: vec![],
1015 entry_points: vec![],
1016 entry_point_role: EntryPointRole::Runtime,
1017 manifest_entries: vec![ManifestEntryRule {
1018 manifests: "**/kibana.jsonc".to_string(),
1019 format: ManifestFormat::Jsonc,
1020 when: conditions(&[("type", Value::String("plugin".into()))]),
1021 entries: vec![
1022 seed(
1023 "public/index.{ts,tsx}",
1024 &[("plugin.browser", Value::Bool(true))],
1025 ),
1026 seed(
1027 "server/index.{ts,tsx}",
1028 &[("plugin.server", Value::Bool(true))],
1029 ),
1030 seed("${plugin.extraPublicDirs}/index.{ts,tsx}", &[]),
1031 ],
1032 }],
1033 config_patterns: vec![],
1034 always_used: vec![],
1035 tooling_dependencies: vec![],
1036 used_exports: vec![],
1037 used_class_members: vec![],
1038 };
1039
1040 let rules = evaluate_manifest_entries(&ext, root);
1041 let paths: Vec<&str> = rules.iter().map(|r| r.pattern.as_str()).collect();
1042
1043 assert!(paths.contains(&"x-pack/plugins/actions/public/index.{ts,tsx}"));
1045 assert!(paths.contains(&"x-pack/plugins/actions/common/index.{ts,tsx}"));
1046 assert!(
1047 !paths.iter().any(|p| p.contains("server/index")),
1048 "server:false must not seed the server entry, got {paths:?}"
1049 );
1050 }
1051
1052 #[test]
1053 fn evaluate_fans_out_over_object_array_fields() {
1054 let dir = tempfile::tempdir().unwrap();
1055 let root = dir.path();
1056 write_manifest(
1057 root,
1058 "extension/manifest.json",
1059 r#"{
1060 "manifest_version": 3,
1061 "content_scripts": [
1062 { "matches": ["https://a.example/*"], "js": ["content/a.js", "content/b.js"] },
1063 { "matches": ["https://b.example/*"], "js": ["content/c.js"] }
1064 ]
1065 }"#,
1066 );
1067 let ext = plugin_with(vec![rule(
1068 "**/manifest.json",
1069 &[("manifest_version", Value::Number(3.into()))],
1070 vec![seed("${content_scripts[*].js}", &[])],
1071 )]);
1072
1073 let reports = check_manifest_entries(&ext, root);
1074 assert!(reports[0].warnings.is_empty());
1075 assert_eq!(
1076 reports[0].matched[0].seeded,
1077 vec![
1078 "extension/content/a.js",
1079 "extension/content/b.js",
1080 "extension/content/c.js",
1081 ]
1082 );
1083 }
1084
1085 fn plugin_with(rules: Vec<ManifestEntryRule>) -> ExternalPluginDef {
1086 ExternalPluginDef {
1087 schema: None,
1088 name: "kibana".to_string(),
1089 detection: None,
1090 enablers: vec![],
1091 entry_points: vec![],
1092 entry_point_role: EntryPointRole::Runtime,
1093 manifest_entries: rules,
1094 config_patterns: vec![],
1095 always_used: vec![],
1096 tooling_dependencies: vec![],
1097 used_exports: vec![],
1098 used_class_members: vec![],
1099 }
1100 }
1101
1102 fn rule(
1103 manifests: &str,
1104 when: &[(&str, Value)],
1105 entries: Vec<ManifestSeedRule>,
1106 ) -> ManifestEntryRule {
1107 ManifestEntryRule {
1108 manifests: manifests.to_string(),
1109 format: ManifestFormat::Jsonc,
1110 when: conditions(when),
1111 entries,
1112 }
1113 }
1114
1115 fn write_manifest(root: &Path, rel: &str, body: &str) {
1116 let p = root.join(rel);
1117 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1118 std::fs::write(p, body).unwrap();
1119 }
1120
1121 #[cfg(unix)]
1122 fn symlink_file(target: &Path, link: &Path) {
1123 std::os::unix::fs::symlink(target, link).expect("create file symlink");
1124 }
1125
1126 #[cfg(windows)]
1127 fn symlink_file(target: &Path, link: &Path) {
1128 std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
1129 }
1130
1131 #[test]
1132 fn manifest_symlinks_must_target_regular_files_inside_root() {
1133 let dir = tempfile::tempdir().expect("create project");
1134 let outside = tempfile::tempdir().expect("create outside dir");
1135 let root = dir.path();
1136 let targets = root.join("targets");
1137 let plugins = root.join("plugins");
1138 std::fs::create_dir_all(&targets).unwrap();
1139 std::fs::create_dir_all(&plugins).unwrap();
1140 std::fs::write(targets.join("inside.jsonc"), r#"{"type":"plugin"}"#).unwrap();
1141 std::fs::write(outside.path().join("outside.jsonc"), r#"{"type":"plugin"}"#).unwrap();
1142
1143 symlink_file(
1144 &targets.join("inside.jsonc"),
1145 &plugins.join("inside-kibana.jsonc"),
1146 );
1147 symlink_file(
1148 &outside.path().join("outside.jsonc"),
1149 &plugins.join("outside-kibana.jsonc"),
1150 );
1151 symlink_file(
1152 &targets.join("missing.jsonc"),
1153 &plugins.join("broken-kibana.jsonc"),
1154 );
1155
1156 let matcher = globset::Glob::new("**/*-kibana.jsonc")
1157 .unwrap()
1158 .compile_matcher();
1159 let paths = discover_manifest_paths(root, &matcher);
1160 let relative: Vec<String> = paths
1161 .iter()
1162 .filter_map(|path| root_relative_forward_slash(path, root))
1163 .collect();
1164
1165 assert_eq!(relative, vec!["plugins/inside-kibana.jsonc"]);
1166 }
1167
1168 fn kinds(reports: &[RuleReport]) -> Vec<WarningKind> {
1169 reports
1170 .iter()
1171 .flat_map(|r| r.warnings.iter().map(|w| w.kind))
1172 .collect()
1173 }
1174
1175 #[test]
1176 fn check_reports_matched_manifests_when_gate_and_seeded_entries() {
1177 let dir = tempfile::tempdir().unwrap();
1178 let root = dir.path();
1179 write_manifest(
1180 root,
1181 "plugins/alpha/kibana.jsonc",
1182 r#"{"type":"plugin","plugin":{"browser":true,"server":true}}"#,
1183 );
1184 write_manifest(
1185 root,
1186 "plugins/beta/kibana.jsonc",
1187 r#"{"type":"plugin","plugin":{"browser":true,"server":false}}"#,
1188 );
1189 let ext = plugin_with(vec![rule(
1190 "**/kibana.jsonc",
1191 &[("type", Value::String("plugin".into()))],
1192 vec![
1193 seed(
1194 "public/index.{ts,tsx}",
1195 &[("plugin.browser", Value::Bool(true))],
1196 ),
1197 seed(
1198 "server/index.{ts,tsx}",
1199 &[("plugin.server", Value::Bool(true))],
1200 ),
1201 ],
1202 )]);
1203
1204 let reports = check_manifest_entries(&ext, root);
1205 assert_eq!(reports.len(), 1);
1206 let report = &reports[0];
1207 assert!(
1208 report.warnings.is_empty(),
1209 "clean plugin, got {:?}",
1210 report.warnings
1211 );
1212 assert_eq!(
1214 report.manifests_matched,
1215 vec![
1216 "plugins/alpha/kibana.jsonc".to_string(),
1217 "plugins/beta/kibana.jsonc".to_string()
1218 ]
1219 );
1220
1221 let beta = report
1222 .matched
1223 .iter()
1224 .find(|m| m.path == "plugins/beta/kibana.jsonc")
1225 .expect("beta matched");
1226 assert!(beta.when_passed);
1227 assert!(beta.seeded.iter().any(|s| s.contains("beta/public/index")));
1228 assert!(
1229 !beta.seeded.iter().any(|s| s.contains("server/index")),
1230 "beta server:false must not seed the server entry, got {:?}",
1231 beta.seeded
1232 );
1233 }
1234
1235 #[test]
1236 fn check_warns_manifests_matched_none() {
1237 let dir = tempfile::tempdir().unwrap();
1238 let ext = plugin_with(vec![rule(
1239 "**/nonexistent.jsonc",
1240 &[],
1241 vec![seed("public/index.ts", &[])],
1242 )]);
1243 let reports = check_manifest_entries(&ext, dir.path());
1244 assert!(kinds(&reports).contains(&WarningKind::ManifestsMatchedNone));
1245 assert_eq!(
1246 reports[0].warnings[0].glob.as_deref(),
1247 Some("**/nonexistent.jsonc")
1248 );
1249 }
1250
1251 #[test]
1252 fn check_warns_field_path_unresolved_on_typo() {
1253 let dir = tempfile::tempdir().unwrap();
1254 let root = dir.path();
1255 write_manifest(
1256 root,
1257 "plugins/alpha/kibana.jsonc",
1258 r#"{"type":"plugin","plugin":{"browser":true}}"#,
1259 );
1260 let ext = plugin_with(vec![rule(
1261 "**/kibana.jsonc",
1262 &[("type", Value::String("plugin".into()))],
1263 vec![seed("${plugin.extarPublicDirs}/index.ts", &[])],
1265 )]);
1266 let reports = check_manifest_entries(&ext, root);
1267 let warn = reports[0]
1268 .warnings
1269 .iter()
1270 .find(|w| w.kind == WarningKind::FieldPathUnresolved)
1271 .expect("field-path-unresolved warning");
1272 assert_eq!(warn.field_path.as_deref(), Some("plugin.extarPublicDirs"));
1273 }
1274
1275 #[test]
1276 fn check_reports_interpolation_limits_without_partial_seeding() {
1277 let dir = tempfile::tempdir().unwrap();
1278 let root = dir.path();
1279 let values = (0..=MAX_MANIFEST_FIELD_VALUES)
1280 .map(|index| index.to_string())
1281 .collect::<Vec<_>>();
1282 write_manifest(
1283 root,
1284 "plugins/alpha/manifest.json",
1285 &serde_json::json!({ "entries": values }).to_string(),
1286 );
1287 let ext = plugin_with(vec![rule(
1288 "**/manifest.json",
1289 &[],
1290 vec![seed("static.ts", &[]), seed("${entries}/index.ts", &[])],
1291 )]);
1292
1293 let reports = check_manifest_entries(&ext, root);
1294 let warning = reports[0]
1295 .warnings
1296 .iter()
1297 .find(|warning| warning.kind == WarningKind::FieldValuesLimitExceeded)
1298 .expect("field-values-limit-exceeded warning");
1299 assert_eq!(warning.field_path.as_deref(), Some("entries"));
1300 assert_eq!(
1301 warning.manifest.as_deref(),
1302 Some("plugins/alpha/manifest.json")
1303 );
1304 assert_eq!(
1305 warning.kind.expansion_limit(),
1306 Some(MAX_MANIFEST_FIELD_VALUES)
1307 );
1308 assert_eq!(
1309 reports[0].matched[0].seeded,
1310 vec!["plugins/alpha/static.ts"],
1311 "a limited template must not suppress valid sibling seeds"
1312 );
1313 }
1314
1315 #[test]
1316 fn check_reports_wildcard_gate_limits_without_a_false_exclusion_warning() {
1317 let dir = tempfile::tempdir().unwrap();
1318 let root = dir.path();
1319 let items = std::iter::repeat_with(|| serde_json::json!({ "enabled": true }))
1320 .take(MAX_MANIFEST_FIELD_VALUES + 1)
1321 .collect::<Vec<_>>();
1322 write_manifest(
1323 root,
1324 "plugins/alpha/manifest.json",
1325 &serde_json::json!({ "items": items }).to_string(),
1326 );
1327 let ext = plugin_with(vec![rule(
1328 "**/manifest.json",
1329 &[("items[*].enabled", Value::Bool(true))],
1330 vec![seed("index.ts", &[])],
1331 )]);
1332
1333 let reports = check_manifest_entries(&ext, root);
1334 assert!(
1335 kinds(&reports).contains(&WarningKind::FieldValuesLimitExceeded),
1336 "wildcard fan-out must report its explicit bound"
1337 );
1338 assert!(
1339 !kinds(&reports).contains(&WarningKind::WhenExcludedAll),
1340 "an evaluation limit is not a false 'when' result"
1341 );
1342 assert!(!reports[0].matched[0].when_passed);
1343 }
1344
1345 #[test]
1346 fn check_warns_when_excluded_all() {
1347 let dir = tempfile::tempdir().unwrap();
1348 let root = dir.path();
1349 write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"package"}"#);
1350 let ext = plugin_with(vec![rule(
1351 "**/kibana.jsonc",
1352 &[("type", Value::String("plugin".into()))],
1353 vec![seed("public/index.ts", &[])],
1354 )]);
1355 let reports = check_manifest_entries(&ext, root);
1356 assert!(kinds(&reports).contains(&WarningKind::WhenExcludedAll));
1357 }
1358
1359 #[test]
1360 fn check_warns_manifest_parse_failed_per_file() {
1361 let dir = tempfile::tempdir().unwrap();
1362 let root = dir.path();
1363 write_manifest(root, "plugins/good/kibana.jsonc", r#"{"type":"plugin"}"#);
1364 write_manifest(root, "plugins/bad/kibana.jsonc", "{ this is not valid json");
1365 let ext = plugin_with(vec![rule(
1366 "**/kibana.jsonc",
1367 &[("type", Value::String("plugin".into()))],
1368 vec![seed("public/index.ts", &[])],
1369 )]);
1370 let reports = check_manifest_entries(&ext, root);
1371 let warn = reports[0]
1372 .warnings
1373 .iter()
1374 .find(|w| w.kind == WarningKind::ManifestParseFailed)
1375 .expect("manifest-parse-failed warning");
1376 assert_eq!(warn.manifest.as_deref(), Some("plugins/bad/kibana.jsonc"));
1378 }
1379
1380 #[test]
1381 fn check_output_is_deterministic_across_walk_order() {
1382 let dir = tempfile::tempdir().unwrap();
1383 let root = dir.path();
1384 for name in ["mmm", "aaa", "zzz", "ccc"] {
1386 write_manifest(
1387 root,
1388 &format!("plugins/{name}/kibana.jsonc"),
1389 r#"{"type":"plugin"}"#,
1390 );
1391 }
1392 let ext = plugin_with(vec![rule(
1393 "**/kibana.jsonc",
1394 &[("type", Value::String("plugin".into()))],
1395 vec![seed("../../../../escape/index.ts", &[])],
1397 )]);
1398 let reports = check_manifest_entries(&ext, root);
1399 let r = &reports[0];
1400 let mut sorted = r.manifests_matched.clone();
1402 sorted.sort();
1403 assert_eq!(
1404 r.manifests_matched, sorted,
1405 "manifests_matched must be sorted"
1406 );
1407 let warn_manifests: Vec<&str> = r
1408 .warnings
1409 .iter()
1410 .filter_map(|w| w.manifest.as_deref())
1411 .collect();
1412 let mut sorted_w = warn_manifests.clone();
1413 sorted_w.sort_unstable();
1414 assert_eq!(
1415 warn_manifests, sorted_w,
1416 "entry-outside-root warnings must be sorted"
1417 );
1418 }
1419
1420 #[test]
1421 fn check_warns_entry_outside_root() {
1422 let dir = tempfile::tempdir().unwrap();
1423 let root = dir.path();
1424 write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"plugin"}"#);
1425 let ext = plugin_with(vec![rule(
1426 "**/kibana.jsonc",
1427 &[("type", Value::String("plugin".into()))],
1428 vec![seed("../../../../escape/index.ts", &[])],
1430 )]);
1431 let reports = check_manifest_entries(&ext, root);
1432 let warn = reports[0]
1433 .warnings
1434 .iter()
1435 .find(|w| w.kind == WarningKind::EntryOutsideRoot)
1436 .expect("entry-outside-root warning");
1437 assert!(warn.entry.as_deref().is_some_and(|e| e.contains("escape")));
1438 assert_eq!(warn.manifest.as_deref(), Some("plugins/alpha/kibana.jsonc"));
1439 }
1440}