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
352fn parse_manifest(source: &str, format: ManifestFormat) -> Option<Value> {
353 match format {
354 ManifestFormat::Jsonc => fallow_config::jsonc::parse_to_value(source).ok(),
355 ManifestFormat::Json => serde_json::from_str(source).ok(),
356 }
357}
358
359fn rule_level_warnings(
366 manifests: &str,
367 matched: usize,
368 parsed: usize,
369 passed: usize,
370 gate_errors: usize,
371 resolved: &BTreeMap<&str, bool>,
372) -> Vec<CheckWarning> {
373 let mut out = Vec::new();
374 if matched == 0 {
375 out.push(CheckWarning::glob(
376 WarningKind::ManifestsMatchedNone,
377 manifests,
378 ));
379 return out;
380 }
381 if parsed > 0 && passed == 0 && gate_errors == 0 {
385 out.push(CheckWarning::glob(WarningKind::WhenExcludedAll, manifests));
386 return out;
387 }
388 if passed == 0 {
389 return out;
390 }
391 for (path, was_resolved) in resolved {
392 if !was_resolved {
393 out.push(CheckWarning::field(
394 WarningKind::FieldPathUnresolved,
395 (*path).to_string(),
396 ));
397 }
398 }
399 out
400}
401
402fn seed_rule_entries(
405 rule: &ManifestEntryRule,
406 manifest: &Value,
407 manifest_path: &Path,
408 root: &Path,
409) -> (Vec<String>, Vec<CheckWarning>) {
410 let rel_manifest = root_relative_forward_slash(manifest_path, root);
411 let mut seeded = Vec::new();
412 let mut warnings = Vec::new();
413 for seed in &rule.entries {
414 match when_matches(manifest, &seed.when) {
415 Ok(true) => {}
416 Ok(false) => continue,
417 Err(error) => {
418 warnings.push(error.into_warning(rel_manifest.clone()));
419 continue;
420 }
421 }
422 let concretes = match expand_interpolations(&seed.path, manifest) {
423 Ok(concretes) => concretes,
424 Err(error) => {
425 warnings.push(error.into_warning(rel_manifest.clone()));
426 continue;
427 }
428 };
429 for concrete in concretes {
430 match normalize_config_path(&concrete, manifest_path, root) {
431 Some(rel) => seeded.push(rel),
432 None => warnings.push(CheckWarning {
433 kind: WarningKind::EntryOutsideRoot,
434 glob: None,
435 field_path: None,
436 manifest: rel_manifest.clone(),
437 entry: Some(concrete),
438 }),
439 }
440 }
441 }
442 (seeded, warnings)
443}
444
445fn emit_report_warnings(plugin_name: &str, report: &RuleReport) {
447 for warning in &report.warnings {
448 match warning.kind {
449 WarningKind::EntriesEmpty => tracing::warn!(
450 "Plugin '{plugin_name}': manifestEntries rule for '{}' has an empty 'entries' \
451 list; it seeds nothing.",
452 report.manifests
453 ),
454 WarningKind::ManifestsMatchedNone => tracing::warn!(
455 "Plugin '{plugin_name}': manifestEntries 'manifests' glob '{}' matched no files. \
456 Check the glob and whether the manifests live under an ignored directory.",
457 report.manifests
458 ),
459 WarningKind::ManifestParseFailed => tracing::warn!(
460 "Plugin '{plugin_name}': manifestEntries skipped manifest '{}' (glob '{}') because \
461 it could not be read or parsed using the rule's declared format.",
462 warning.manifest.as_deref().unwrap_or(""),
463 report.manifests
464 ),
465 WarningKind::FieldValuesLimitExceeded => tracing::warn!(
466 "Plugin '{plugin_name}': manifestEntries field path '{}' in manifest '{}' exceeded \
467 the traversal value limit of {}. The affected gate or template was skipped without \
468 partial seeding.",
469 warning.field_path.as_deref().unwrap_or(""),
470 warning.manifest.as_deref().unwrap_or(""),
471 warning
472 .kind
473 .expansion_limit()
474 .unwrap_or(MAX_MANIFEST_FIELD_VALUES)
475 ),
476 WarningKind::EntryExpansionLimitExceeded => tracing::warn!(
477 "Plugin '{plugin_name}': manifestEntries template '{}' in manifest '{}' exceeded \
478 the concrete entry limit of {}. No entries were seeded from that template.",
479 warning.entry.as_deref().unwrap_or(""),
480 warning.manifest.as_deref().unwrap_or(""),
481 warning
482 .kind
483 .expansion_limit()
484 .unwrap_or(MAX_MANIFEST_ENTRY_EXPANSIONS)
485 ),
486 WarningKind::WhenExcludedAll => tracing::warn!(
487 "Plugin '{plugin_name}': manifestEntries 'when' gate excluded all matched \
488 manifest(s) for glob '{}'. No entries were seeded.",
489 report.manifests
490 ),
491 WarningKind::FieldPathUnresolved => tracing::warn!(
492 "Plugin '{plugin_name}': manifestEntries field path '{}' resolved in none of the \
493 gated manifest(s). Likely a typo in a 'when' key or a ${{...}} interpolation.",
494 warning.field_path.as_deref().unwrap_or("")
495 ),
496 WarningKind::EntryOutsideRoot => tracing::warn!(
497 "Plugin '{plugin_name}': manifestEntries entry '{}' (from manifest '{}') resolved \
498 outside the project root and was skipped.",
499 warning.entry.as_deref().unwrap_or(""),
500 warning.manifest.as_deref().unwrap_or("")
501 ),
502 WarningKind::SeededPathsMissing => {}
504 }
505 }
506}
507
508fn referenced_field_paths(rule: &ManifestEntryRule) -> Vec<ManifestFieldPath> {
511 let mut paths: Vec<ManifestFieldPath> = rule
512 .when
513 .iter()
514 .filter(|(_, condition)| condition_requires_present_value(condition))
515 .map(|(path, _)| path.clone())
516 .collect();
517 for seed in &rule.entries {
518 paths.extend(
519 seed.when
520 .iter()
521 .filter(|(_, condition)| condition_requires_present_value(condition))
522 .map(|(path, _)| path.clone()),
523 );
524 paths.extend(seed.path.parts().iter().filter_map(|part| match part {
525 ManifestPathPart::Field(path) => Some(path.clone()),
526 ManifestPathPart::Literal(_) => None,
527 }));
528 }
529 paths.sort();
530 paths.dedup();
531 paths
532}
533
534fn expand_interpolations(
538 path: &ManifestPathTemplate,
539 manifest: &Value,
540) -> Result<Vec<String>, ExpansionError> {
541 let mut expanded = vec![String::new()];
542 for part in path.parts() {
543 match part {
544 ManifestPathPart::Literal(literal) => {
545 for value in &mut expanded {
546 value.push_str(literal);
547 }
548 }
549 ManifestPathPart::Field(field) => {
550 let values = field_segment_values(manifest, field)?;
551 if values.is_empty() {
552 return Ok(Vec::new());
553 }
554
555 let Some(next_len) = expanded.len().checked_mul(values.len()) else {
556 return Err(ExpansionError::EntryPaths {
557 template: path.as_str().to_string(),
558 });
559 };
560 if next_len > MAX_MANIFEST_ENTRY_EXPANSIONS {
561 return Err(ExpansionError::EntryPaths {
562 template: path.as_str().to_string(),
563 });
564 }
565
566 let mut next = Vec::with_capacity(next_len);
567 for prefix in &expanded {
568 for value in &values {
569 let mut concrete = String::with_capacity(prefix.len() + value.len());
570 concrete.push_str(prefix);
571 concrete.push_str(value);
572 next.push(concrete);
573 }
574 }
575 expanded = next;
576 }
577 }
578 }
579 Ok(expanded)
580}
581
582fn field_segment_values(
585 manifest: &Value,
586 field: &ManifestFieldPath,
587) -> Result<Vec<String>, ExpansionError> {
588 let mut values = Vec::new();
589 for value in field_values(manifest, field)? {
590 match value {
591 Value::Array(items) => {
592 for item in items.iter().filter_map(scalar_segment) {
593 push_field_segment(&mut values, item, field)?;
594 }
595 }
596 value => {
597 if let Some(segment) = scalar_segment(value) {
598 push_field_segment(&mut values, segment, field)?;
599 }
600 }
601 }
602 }
603 Ok(values)
604}
605
606fn push_field_segment(
607 values: &mut Vec<String>,
608 value: String,
609 field: &ManifestFieldPath,
610) -> Result<(), ExpansionError> {
611 if values.len() == MAX_MANIFEST_FIELD_VALUES {
612 return Err(ExpansionError::FieldValues {
613 field_path: field.as_str().to_string(),
614 });
615 }
616 values.push(value);
617 Ok(())
618}
619
620fn scalar_segment(value: &Value) -> Option<String> {
621 match value {
622 Value::String(s) if !s.is_empty() => Some(s.clone()),
623 Value::Number(n) => Some(n.to_string()),
624 _ => None,
625 }
626}
627
628fn when_matches(
631 manifest: &Value,
632 when: &BTreeMap<ManifestFieldPath, ManifestCondition>,
633) -> Result<bool, ExpansionError> {
634 for (path, condition) in when {
635 let values = field_values(manifest, path)?;
636 let matches = match condition {
637 ManifestCondition::Equals(expected) => values.contains(&expected),
638 ManifestCondition::Exists(predicate) => values.is_empty() != predicate.exists,
639 };
640 if !matches {
641 return Ok(false);
642 }
643 }
644 Ok(true)
645}
646
647fn condition_requires_present_value(condition: &ManifestCondition) -> bool {
648 match condition {
649 ManifestCondition::Equals(_) => true,
650 ManifestCondition::Exists(predicate) => predicate.exists,
651 }
652}
653
654fn field_values<'a>(
656 value: &'a Value,
657 path: &ManifestFieldPath,
658) -> Result<Vec<&'a Value>, ExpansionError> {
659 let mut current = vec![value];
660 for segment in path.segments() {
661 let mut next = Vec::new();
662 for value in current {
663 match segment {
664 ManifestFieldSegment::Key(key) => {
665 if let Some(child) = value.get(key) {
666 push_field_value(&mut next, child, path)?;
667 }
668 }
669 ManifestFieldSegment::Each => {
670 if let Value::Array(items) = value {
671 for item in items {
672 push_field_value(&mut next, item, path)?;
673 }
674 }
675 }
676 }
677 }
678 current = next;
679 }
680 Ok(current)
681}
682
683fn push_field_value<'a>(
684 values: &mut Vec<&'a Value>,
685 value: &'a Value,
686 path: &ManifestFieldPath,
687) -> Result<(), ExpansionError> {
688 if values.len() == MAX_MANIFEST_FIELD_VALUES {
689 return Err(ExpansionError::FieldValues {
690 field_path: path.as_str().to_string(),
691 });
692 }
693 values.push(value);
694 Ok(())
695}
696
697fn discover_manifest_paths(root: &Path, matcher: &globset::GlobMatcher) -> Vec<PathBuf> {
701 let mut out = Vec::new();
702 let canonical_root = root.canonicalize().ok();
703 let walker = ignore::WalkBuilder::new(root)
704 .hidden(false)
705 .git_ignore(true)
706 .git_global(true)
707 .git_exclude(true)
708 .filter_entry(|entry| entry.file_name() != "node_modules")
709 .build();
710 for entry in walker.flatten() {
711 let Some(file_type) = entry.file_type() else {
712 continue;
713 };
714 if file_type.is_dir() {
715 continue;
716 }
717 let path = entry.path();
718 if file_type.is_symlink()
719 && !is_contained_regular_file_symlink(path, canonical_root.as_deref())
720 {
721 tracing::debug!(
722 path = %path.display(),
723 "skipping manifest symlink with a broken, non-file, or outside-root target"
724 );
725 continue;
726 }
727 if let Some(rel) = root_relative_forward_slash(path, root)
728 && matcher.is_match(Path::new(&rel))
729 {
730 out.push(path.to_path_buf());
731 }
732 }
733 out.sort();
737 out
738}
739
740fn is_contained_regular_file_symlink(path: &Path, canonical_root: Option<&Path>) -> bool {
741 let Some(root) = canonical_root else {
742 return false;
743 };
744 let Ok(target) = path.canonicalize() else {
745 return false;
746 };
747 target.starts_with(root) && target.metadata().is_ok_and(|metadata| metadata.is_file())
748}
749
750fn root_relative_forward_slash(file: &Path, root: &Path) -> Option<String> {
753 let rel = file.strip_prefix(root).ok()?;
754 Some(rel.to_string_lossy().replace('\\', "/"))
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760 use fallow_config::{
761 EntryPointRole, ManifestExistsPredicate, ManifestFormat, ManifestSeedRule,
762 };
763
764 fn json(text: &str) -> Value {
765 serde_json::from_str(text).unwrap()
766 }
767
768 fn seed(path: &str, when: &[(&str, Value)]) -> ManifestSeedRule {
769 ManifestSeedRule {
770 path: path.parse().unwrap(),
771 when: conditions(when),
772 }
773 }
774
775 fn field(path: &str) -> ManifestFieldPath {
776 path.parse().unwrap()
777 }
778
779 fn template(path: &str) -> ManifestPathTemplate {
780 path.parse().unwrap()
781 }
782
783 fn conditions(when: &[(&str, Value)]) -> BTreeMap<ManifestFieldPath, ManifestCondition> {
784 when.iter()
785 .map(|(path, expected)| (field(path), ManifestCondition::Equals(expected.clone())))
786 .collect()
787 }
788
789 fn exists(path: &str, expected: bool) -> (ManifestFieldPath, ManifestCondition) {
790 (
791 field(path),
792 ManifestCondition::Exists(ManifestExistsPredicate { exists: expected }),
793 )
794 }
795
796 #[test]
797 fn field_values_traverse_nested_fields_and_object_arrays() {
798 let m = json(r#"{"plugin": {"browser": true, "id": "actions"}}"#);
799 assert_eq!(
800 field_values(&m, &field("plugin.browser")).unwrap(),
801 vec![&Value::Bool(true)]
802 );
803 assert_eq!(
804 field_values(&m, &field("plugin.id")).unwrap(),
805 vec![&Value::String("actions".into())]
806 );
807 assert!(
808 field_values(&m, &field("plugin.missing"))
809 .unwrap()
810 .is_empty()
811 );
812 assert!(field_values(&m, &field("absent.field")).unwrap().is_empty());
813
814 let m = json(
815 r#"{"content_scripts":[{"js":["a.js","b.js"]},null,{"js":["c.js"]},"invalid",{"css":[]}]}"#,
816 );
817 assert_eq!(
818 field_segment_values(&m, &field("content_scripts[*].js")).unwrap(),
819 vec!["a.js", "b.js", "c.js"]
820 );
821
822 let nested = json(
823 r#"{"groups":[{"entries":[{"path":"a.js"},{"path":"b.js"}]},{"entries":[{"path":"c.js"}]}]}"#,
824 );
825 assert_eq!(
826 field_segment_values(&nested, &field("groups[*].entries[*].path")).unwrap(),
827 vec!["a.js", "b.js", "c.js"]
828 );
829 }
830
831 #[test]
832 fn when_matches_is_strict_equality_and_presence_is_not_matched() {
833 let m = json(r#"{"type": "plugin", "plugin": {"browser": false}}"#);
834 let mut when = BTreeMap::new();
835 when.insert(
836 field("type"),
837 ManifestCondition::Equals(Value::String("plugin".into())),
838 );
839 assert!(when_matches(&m, &when).unwrap());
840
841 let mut when_browser = BTreeMap::new();
844 when_browser.insert(
845 field("plugin.browser"),
846 ManifestCondition::Equals(Value::Bool(true)),
847 );
848 assert!(!when_matches(&m, &when_browser).unwrap());
849
850 assert!(when_matches(&m, &BTreeMap::new()).unwrap());
852
853 let manifest = json(r#"{"plugins":[{"kind":"worker"},{"kind":"browser"}]}"#);
854 let wildcard = conditions(&[("plugins[*].kind", Value::String("browser".into()))]);
855 assert!(when_matches(&manifest, &wildcard).unwrap());
856
857 let structured = json(r#"{"array":["worker"],"object":{"kind":"browser"}}"#);
858 let structured_when = BTreeMap::from([
859 (
860 field("array"),
861 ManifestCondition::Equals(json(r#"["worker"]"#)),
862 ),
863 (
864 field("object"),
865 ManifestCondition::Equals(json(r#"{"kind":"browser"}"#)),
866 ),
867 ]);
868 assert!(when_matches(&structured, &structured_when).unwrap());
869 }
870
871 #[test]
872 fn exists_conditions_test_presence_without_truthiness() {
873 let manifest = json(
874 r#"{"presentFalse":false,"presentNull":null,"presentEmpty":"","presentObject":{},"items":[]}"#,
875 );
876 for path in [
877 "presentFalse",
878 "presentNull",
879 "presentEmpty",
880 "presentObject",
881 "items",
882 ] {
883 assert!(
884 when_matches(&manifest, &BTreeMap::from([exists(path, true)])).unwrap(),
885 "{path} is present regardless of its value"
886 );
887 }
888 assert!(when_matches(&manifest, &BTreeMap::from([exists("missing", false)])).unwrap());
889 assert!(!when_matches(&manifest, &BTreeMap::from([exists("missing", true)])).unwrap());
890 assert!(
891 when_matches(
892 &manifest,
893 &BTreeMap::from([exists("items[*].value", false)])
894 )
895 .unwrap()
896 );
897 }
898
899 #[test]
900 fn exists_false_can_gate_a_rule_without_an_unresolved_path_warning() {
901 let dir = tempfile::tempdir().unwrap();
902 let root = dir.path();
903 write_manifest(root, "plugins/alpha/manifest.json", r#"{"name":"alpha"}"#);
904 let mut manifest_rule = rule("**/manifest.json", &[], vec![seed("index.ts", &[])]);
905 manifest_rule.when = BTreeMap::from([exists("main", false)]);
906
907 let reports = check_manifest_entries(&plugin_with(vec![manifest_rule]), root);
908 assert!(reports[0].warnings.is_empty());
909 assert!(reports[0].matched[0].when_passed);
910 assert_eq!(reports[0].matched[0].seeded, vec!["plugins/alpha/index.ts"]);
911 }
912
913 #[test]
914 fn parse_manifest_honors_the_declared_format() {
915 let jsonc_only = r#"{
916 // JSONC comment
917 "type": "plugin",
918 }"#;
919
920 assert!(parse_manifest(jsonc_only, ManifestFormat::Jsonc).is_some());
921 assert!(parse_manifest(jsonc_only, ManifestFormat::Json).is_none());
922 assert!(parse_manifest(r#"{"type":"plugin"}"#, ManifestFormat::Json).is_some());
923 }
924
925 #[test]
926 fn expand_interpolations_string_array_and_missing() {
927 let m = json(r#"{"plugin": {"extraPublicDirs": ["common", "types"], "id": "actions"}}"#);
928 assert_eq!(
930 expand_interpolations(&template("${plugin.id}/index.ts"), &m).unwrap(),
931 vec!["actions/index.ts"]
932 );
933 assert_eq!(
935 expand_interpolations(&template("${plugin.extraPublicDirs}/index.{ts,tsx}"), &m)
936 .unwrap(),
937 vec!["common/index.{ts,tsx}", "types/index.{ts,tsx}"]
938 );
939 assert!(
941 expand_interpolations(&template("${plugin.absent}/index.ts"), &m)
942 .unwrap()
943 .is_empty()
944 );
945 assert_eq!(
947 expand_interpolations(&template("public/index.{ts,tsx}"), &m).unwrap(),
948 vec!["public/index.{ts,tsx}"]
949 );
950 }
951
952 #[test]
953 fn interpolation_limits_are_explicit_errors() {
954 let too_many: Vec<Value> = (0..=MAX_MANIFEST_FIELD_VALUES)
955 .map(|index| Value::String(index.to_string()))
956 .collect();
957 let manifest = serde_json::json!({ "values": too_many });
958 assert_eq!(
959 expand_interpolations(&template("${values}/index.ts"), &manifest),
960 Err(ExpansionError::FieldValues {
961 field_path: "values".to_string(),
962 })
963 );
964
965 let factors = (0..65)
966 .map(|index| Value::String(index.to_string()))
967 .collect::<Vec<_>>();
968 let manifest = serde_json::json!({ "left": factors, "right": factors });
969 assert_eq!(
970 expand_interpolations(&template("${left}/${right}/index.ts"), &manifest),
971 Err(ExpansionError::EntryPaths {
972 template: "${left}/${right}/index.ts".to_string(),
973 })
974 );
975 }
976
977 #[test]
978 fn evaluate_seeds_relative_to_manifest_dir_with_when_and_fanout() {
979 let dir = tempfile::tempdir().unwrap();
980 let root = dir.path();
981 let manifest_dir = root.join("x-pack/plugins/actions");
982 std::fs::create_dir_all(&manifest_dir).unwrap();
983 let manifest_path = manifest_dir.join("kibana.jsonc");
984 std::fs::write(
985 &manifest_path,
986 r#"{
987 // a real Kibana-shaped manifest
988 "type": "plugin",
989 "plugin": { "browser": true, "server": false, "extraPublicDirs": ["common"] },
990 }"#,
991 )
992 .unwrap();
993
994 let ext = ExternalPluginDef {
995 schema: None,
996 name: "kibana".to_string(),
997 detection: None,
998 enablers: vec![],
999 entry_points: vec![],
1000 entry_point_role: EntryPointRole::Runtime,
1001 manifest_entries: vec![ManifestEntryRule {
1002 manifests: "**/kibana.jsonc".to_string(),
1003 format: ManifestFormat::Jsonc,
1004 when: conditions(&[("type", Value::String("plugin".into()))]),
1005 entries: vec![
1006 seed(
1007 "public/index.{ts,tsx}",
1008 &[("plugin.browser", Value::Bool(true))],
1009 ),
1010 seed(
1011 "server/index.{ts,tsx}",
1012 &[("plugin.server", Value::Bool(true))],
1013 ),
1014 seed("${plugin.extraPublicDirs}/index.{ts,tsx}", &[]),
1015 ],
1016 }],
1017 config_patterns: vec![],
1018 always_used: vec![],
1019 tooling_dependencies: vec![],
1020 used_exports: vec![],
1021 used_class_members: vec![],
1022 };
1023
1024 let rules = evaluate_manifest_entries(&ext, root);
1025 let paths: Vec<&str> = rules.iter().map(|r| r.pattern.as_str()).collect();
1026
1027 assert!(paths.contains(&"x-pack/plugins/actions/public/index.{ts,tsx}"));
1029 assert!(paths.contains(&"x-pack/plugins/actions/common/index.{ts,tsx}"));
1030 assert!(
1031 !paths.iter().any(|p| p.contains("server/index")),
1032 "server:false must not seed the server entry, got {paths:?}"
1033 );
1034 }
1035
1036 #[test]
1037 fn evaluate_fans_out_over_object_array_fields() {
1038 let dir = tempfile::tempdir().unwrap();
1039 let root = dir.path();
1040 write_manifest(
1041 root,
1042 "extension/manifest.json",
1043 r#"{
1044 "manifest_version": 3,
1045 "content_scripts": [
1046 { "matches": ["https://a.example/*"], "js": ["content/a.js", "content/b.js"] },
1047 { "matches": ["https://b.example/*"], "js": ["content/c.js"] }
1048 ]
1049 }"#,
1050 );
1051 let ext = plugin_with(vec![rule(
1052 "**/manifest.json",
1053 &[("manifest_version", Value::Number(3.into()))],
1054 vec![seed("${content_scripts[*].js}", &[])],
1055 )]);
1056
1057 let reports = check_manifest_entries(&ext, root);
1058 assert!(reports[0].warnings.is_empty());
1059 assert_eq!(
1060 reports[0].matched[0].seeded,
1061 vec![
1062 "extension/content/a.js",
1063 "extension/content/b.js",
1064 "extension/content/c.js",
1065 ]
1066 );
1067 }
1068
1069 fn plugin_with(rules: Vec<ManifestEntryRule>) -> ExternalPluginDef {
1070 ExternalPluginDef {
1071 schema: None,
1072 name: "kibana".to_string(),
1073 detection: None,
1074 enablers: vec![],
1075 entry_points: vec![],
1076 entry_point_role: EntryPointRole::Runtime,
1077 manifest_entries: rules,
1078 config_patterns: vec![],
1079 always_used: vec![],
1080 tooling_dependencies: vec![],
1081 used_exports: vec![],
1082 used_class_members: vec![],
1083 }
1084 }
1085
1086 fn rule(
1087 manifests: &str,
1088 when: &[(&str, Value)],
1089 entries: Vec<ManifestSeedRule>,
1090 ) -> ManifestEntryRule {
1091 ManifestEntryRule {
1092 manifests: manifests.to_string(),
1093 format: ManifestFormat::Jsonc,
1094 when: conditions(when),
1095 entries,
1096 }
1097 }
1098
1099 fn write_manifest(root: &Path, rel: &str, body: &str) {
1100 let p = root.join(rel);
1101 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1102 std::fs::write(p, body).unwrap();
1103 }
1104
1105 #[cfg(unix)]
1106 fn symlink_file(target: &Path, link: &Path) {
1107 std::os::unix::fs::symlink(target, link).expect("create file symlink");
1108 }
1109
1110 #[cfg(windows)]
1111 fn symlink_file(target: &Path, link: &Path) {
1112 std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
1113 }
1114
1115 #[test]
1116 fn manifest_symlinks_must_target_regular_files_inside_root() {
1117 let dir = tempfile::tempdir().expect("create project");
1118 let outside = tempfile::tempdir().expect("create outside dir");
1119 let root = dir.path();
1120 let targets = root.join("targets");
1121 let plugins = root.join("plugins");
1122 std::fs::create_dir_all(&targets).unwrap();
1123 std::fs::create_dir_all(&plugins).unwrap();
1124 std::fs::write(targets.join("inside.jsonc"), r#"{"type":"plugin"}"#).unwrap();
1125 std::fs::write(outside.path().join("outside.jsonc"), r#"{"type":"plugin"}"#).unwrap();
1126
1127 symlink_file(
1128 &targets.join("inside.jsonc"),
1129 &plugins.join("inside-kibana.jsonc"),
1130 );
1131 symlink_file(
1132 &outside.path().join("outside.jsonc"),
1133 &plugins.join("outside-kibana.jsonc"),
1134 );
1135 symlink_file(
1136 &targets.join("missing.jsonc"),
1137 &plugins.join("broken-kibana.jsonc"),
1138 );
1139
1140 let matcher = globset::Glob::new("**/*-kibana.jsonc")
1141 .unwrap()
1142 .compile_matcher();
1143 let paths = discover_manifest_paths(root, &matcher);
1144 let relative: Vec<String> = paths
1145 .iter()
1146 .filter_map(|path| root_relative_forward_slash(path, root))
1147 .collect();
1148
1149 assert_eq!(relative, vec!["plugins/inside-kibana.jsonc"]);
1150 }
1151
1152 fn kinds(reports: &[RuleReport]) -> Vec<WarningKind> {
1153 reports
1154 .iter()
1155 .flat_map(|r| r.warnings.iter().map(|w| w.kind))
1156 .collect()
1157 }
1158
1159 #[test]
1160 fn check_reports_matched_manifests_when_gate_and_seeded_entries() {
1161 let dir = tempfile::tempdir().unwrap();
1162 let root = dir.path();
1163 write_manifest(
1164 root,
1165 "plugins/alpha/kibana.jsonc",
1166 r#"{"type":"plugin","plugin":{"browser":true,"server":true}}"#,
1167 );
1168 write_manifest(
1169 root,
1170 "plugins/beta/kibana.jsonc",
1171 r#"{"type":"plugin","plugin":{"browser":true,"server":false}}"#,
1172 );
1173 let ext = plugin_with(vec![rule(
1174 "**/kibana.jsonc",
1175 &[("type", Value::String("plugin".into()))],
1176 vec![
1177 seed(
1178 "public/index.{ts,tsx}",
1179 &[("plugin.browser", Value::Bool(true))],
1180 ),
1181 seed(
1182 "server/index.{ts,tsx}",
1183 &[("plugin.server", Value::Bool(true))],
1184 ),
1185 ],
1186 )]);
1187
1188 let reports = check_manifest_entries(&ext, root);
1189 assert_eq!(reports.len(), 1);
1190 let report = &reports[0];
1191 assert!(
1192 report.warnings.is_empty(),
1193 "clean plugin, got {:?}",
1194 report.warnings
1195 );
1196 assert_eq!(
1198 report.manifests_matched,
1199 vec![
1200 "plugins/alpha/kibana.jsonc".to_string(),
1201 "plugins/beta/kibana.jsonc".to_string()
1202 ]
1203 );
1204
1205 let beta = report
1206 .matched
1207 .iter()
1208 .find(|m| m.path == "plugins/beta/kibana.jsonc")
1209 .expect("beta matched");
1210 assert!(beta.when_passed);
1211 assert!(beta.seeded.iter().any(|s| s.contains("beta/public/index")));
1212 assert!(
1213 !beta.seeded.iter().any(|s| s.contains("server/index")),
1214 "beta server:false must not seed the server entry, got {:?}",
1215 beta.seeded
1216 );
1217 }
1218
1219 #[test]
1220 fn check_warns_manifests_matched_none() {
1221 let dir = tempfile::tempdir().unwrap();
1222 let ext = plugin_with(vec![rule(
1223 "**/nonexistent.jsonc",
1224 &[],
1225 vec![seed("public/index.ts", &[])],
1226 )]);
1227 let reports = check_manifest_entries(&ext, dir.path());
1228 assert!(kinds(&reports).contains(&WarningKind::ManifestsMatchedNone));
1229 assert_eq!(
1230 reports[0].warnings[0].glob.as_deref(),
1231 Some("**/nonexistent.jsonc")
1232 );
1233 }
1234
1235 #[test]
1236 fn check_warns_field_path_unresolved_on_typo() {
1237 let dir = tempfile::tempdir().unwrap();
1238 let root = dir.path();
1239 write_manifest(
1240 root,
1241 "plugins/alpha/kibana.jsonc",
1242 r#"{"type":"plugin","plugin":{"browser":true}}"#,
1243 );
1244 let ext = plugin_with(vec![rule(
1245 "**/kibana.jsonc",
1246 &[("type", Value::String("plugin".into()))],
1247 vec![seed("${plugin.extarPublicDirs}/index.ts", &[])],
1249 )]);
1250 let reports = check_manifest_entries(&ext, root);
1251 let warn = reports[0]
1252 .warnings
1253 .iter()
1254 .find(|w| w.kind == WarningKind::FieldPathUnresolved)
1255 .expect("field-path-unresolved warning");
1256 assert_eq!(warn.field_path.as_deref(), Some("plugin.extarPublicDirs"));
1257 }
1258
1259 #[test]
1260 fn check_reports_interpolation_limits_without_partial_seeding() {
1261 let dir = tempfile::tempdir().unwrap();
1262 let root = dir.path();
1263 let values = (0..=MAX_MANIFEST_FIELD_VALUES)
1264 .map(|index| index.to_string())
1265 .collect::<Vec<_>>();
1266 write_manifest(
1267 root,
1268 "plugins/alpha/manifest.json",
1269 &serde_json::json!({ "entries": values }).to_string(),
1270 );
1271 let ext = plugin_with(vec![rule(
1272 "**/manifest.json",
1273 &[],
1274 vec![seed("static.ts", &[]), seed("${entries}/index.ts", &[])],
1275 )]);
1276
1277 let reports = check_manifest_entries(&ext, root);
1278 let warning = reports[0]
1279 .warnings
1280 .iter()
1281 .find(|warning| warning.kind == WarningKind::FieldValuesLimitExceeded)
1282 .expect("field-values-limit-exceeded warning");
1283 assert_eq!(warning.field_path.as_deref(), Some("entries"));
1284 assert_eq!(
1285 warning.manifest.as_deref(),
1286 Some("plugins/alpha/manifest.json")
1287 );
1288 assert_eq!(
1289 warning.kind.expansion_limit(),
1290 Some(MAX_MANIFEST_FIELD_VALUES)
1291 );
1292 assert_eq!(
1293 reports[0].matched[0].seeded,
1294 vec!["plugins/alpha/static.ts"],
1295 "a limited template must not suppress valid sibling seeds"
1296 );
1297 }
1298
1299 #[test]
1300 fn check_reports_wildcard_gate_limits_without_a_false_exclusion_warning() {
1301 let dir = tempfile::tempdir().unwrap();
1302 let root = dir.path();
1303 let items = std::iter::repeat_with(|| serde_json::json!({ "enabled": true }))
1304 .take(MAX_MANIFEST_FIELD_VALUES + 1)
1305 .collect::<Vec<_>>();
1306 write_manifest(
1307 root,
1308 "plugins/alpha/manifest.json",
1309 &serde_json::json!({ "items": items }).to_string(),
1310 );
1311 let ext = plugin_with(vec![rule(
1312 "**/manifest.json",
1313 &[("items[*].enabled", Value::Bool(true))],
1314 vec![seed("index.ts", &[])],
1315 )]);
1316
1317 let reports = check_manifest_entries(&ext, root);
1318 assert!(
1319 kinds(&reports).contains(&WarningKind::FieldValuesLimitExceeded),
1320 "wildcard fan-out must report its explicit bound"
1321 );
1322 assert!(
1323 !kinds(&reports).contains(&WarningKind::WhenExcludedAll),
1324 "an evaluation limit is not a false 'when' result"
1325 );
1326 assert!(!reports[0].matched[0].when_passed);
1327 }
1328
1329 #[test]
1330 fn check_warns_when_excluded_all() {
1331 let dir = tempfile::tempdir().unwrap();
1332 let root = dir.path();
1333 write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"package"}"#);
1334 let ext = plugin_with(vec![rule(
1335 "**/kibana.jsonc",
1336 &[("type", Value::String("plugin".into()))],
1337 vec![seed("public/index.ts", &[])],
1338 )]);
1339 let reports = check_manifest_entries(&ext, root);
1340 assert!(kinds(&reports).contains(&WarningKind::WhenExcludedAll));
1341 }
1342
1343 #[test]
1344 fn check_warns_manifest_parse_failed_per_file() {
1345 let dir = tempfile::tempdir().unwrap();
1346 let root = dir.path();
1347 write_manifest(root, "plugins/good/kibana.jsonc", r#"{"type":"plugin"}"#);
1348 write_manifest(root, "plugins/bad/kibana.jsonc", "{ this is not valid json");
1349 let ext = plugin_with(vec![rule(
1350 "**/kibana.jsonc",
1351 &[("type", Value::String("plugin".into()))],
1352 vec![seed("public/index.ts", &[])],
1353 )]);
1354 let reports = check_manifest_entries(&ext, root);
1355 let warn = reports[0]
1356 .warnings
1357 .iter()
1358 .find(|w| w.kind == WarningKind::ManifestParseFailed)
1359 .expect("manifest-parse-failed warning");
1360 assert_eq!(warn.manifest.as_deref(), Some("plugins/bad/kibana.jsonc"));
1362 }
1363
1364 #[test]
1365 fn check_output_is_deterministic_across_walk_order() {
1366 let dir = tempfile::tempdir().unwrap();
1367 let root = dir.path();
1368 for name in ["mmm", "aaa", "zzz", "ccc"] {
1370 write_manifest(
1371 root,
1372 &format!("plugins/{name}/kibana.jsonc"),
1373 r#"{"type":"plugin"}"#,
1374 );
1375 }
1376 let ext = plugin_with(vec![rule(
1377 "**/kibana.jsonc",
1378 &[("type", Value::String("plugin".into()))],
1379 vec![seed("../../../../escape/index.ts", &[])],
1381 )]);
1382 let reports = check_manifest_entries(&ext, root);
1383 let r = &reports[0];
1384 let mut sorted = r.manifests_matched.clone();
1386 sorted.sort();
1387 assert_eq!(
1388 r.manifests_matched, sorted,
1389 "manifests_matched must be sorted"
1390 );
1391 let warn_manifests: Vec<&str> = r
1392 .warnings
1393 .iter()
1394 .filter_map(|w| w.manifest.as_deref())
1395 .collect();
1396 let mut sorted_w = warn_manifests.clone();
1397 sorted_w.sort_unstable();
1398 assert_eq!(
1399 warn_manifests, sorted_w,
1400 "entry-outside-root warnings must be sorted"
1401 );
1402 }
1403
1404 #[test]
1405 fn check_warns_entry_outside_root() {
1406 let dir = tempfile::tempdir().unwrap();
1407 let root = dir.path();
1408 write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"plugin"}"#);
1409 let ext = plugin_with(vec![rule(
1410 "**/kibana.jsonc",
1411 &[("type", Value::String("plugin".into()))],
1412 vec![seed("../../../../escape/index.ts", &[])],
1414 )]);
1415 let reports = check_manifest_entries(&ext, root);
1416 let warn = reports[0]
1417 .warnings
1418 .iter()
1419 .find(|w| w.kind == WarningKind::EntryOutsideRoot)
1420 .expect("entry-outside-root warning");
1421 assert!(warn.entry.as_deref().is_some_and(|e| e.contains("escape")));
1422 assert_eq!(warn.manifest.as_deref(), Some("plugins/alpha/kibana.jsonc"));
1423 }
1424}