1use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use fallow_config::{ExternalPluginDef, ManifestEntryRule};
21use serde_json::Value;
22
23use super::PathRule;
24use super::config_parser::normalize_config_path;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum WarningKind {
32 ManifestsMatchedNone,
34 WhenExcludedAll,
36 FieldPathUnresolved,
38 EntriesEmpty,
40 ManifestParseFailed,
42 EntryOutsideRoot,
44 SeededPathsMissing,
47}
48
49impl WarningKind {
50 #[must_use]
52 pub fn as_kebab(self) -> &'static str {
53 match self {
54 Self::ManifestsMatchedNone => "manifests-matched-none",
55 Self::WhenExcludedAll => "when-excluded-all",
56 Self::FieldPathUnresolved => "field-path-unresolved",
57 Self::EntriesEmpty => "entries-empty",
58 Self::ManifestParseFailed => "manifest-parse-failed",
59 Self::EntryOutsideRoot => "entry-outside-root",
60 Self::SeededPathsMissing => "seeded-paths-missing",
61 }
62 }
63}
64
65#[derive(Debug, Clone)]
68pub struct CheckWarning {
69 pub kind: WarningKind,
70 pub glob: Option<String>,
72 pub field_path: Option<String>,
74 pub manifest: Option<String>,
76 pub entry: Option<String>,
78}
79
80impl CheckWarning {
81 fn glob(kind: WarningKind, glob: &str) -> Self {
83 Self {
84 kind,
85 glob: Some(glob.to_string()),
86 field_path: None,
87 manifest: None,
88 entry: None,
89 }
90 }
91
92 fn field(kind: WarningKind, field_path: String) -> Self {
94 Self {
95 kind,
96 glob: None,
97 field_path: Some(field_path),
98 manifest: None,
99 entry: None,
100 }
101 }
102
103 fn manifest(kind: WarningKind, manifest: String) -> Self {
105 Self {
106 kind,
107 glob: None,
108 field_path: None,
109 manifest: Some(manifest),
110 entry: None,
111 }
112 }
113}
114
115#[derive(Debug, Clone)]
117pub struct ManifestResult {
118 pub path: String,
120 pub when_passed: bool,
122 pub seeded: Vec<String>,
125}
126
127#[derive(Debug, Clone)]
131pub struct RuleReport {
132 pub manifests: String,
134 pub manifests_matched: Vec<String>,
136 pub matched: Vec<ManifestResult>,
138 pub warnings: Vec<CheckWarning>,
141}
142
143#[must_use]
154pub fn evaluate_manifest_entries(ext: &ExternalPluginDef, root: &Path) -> Vec<PathRule> {
155 let mut out = Vec::new();
156 for rule in &ext.manifest_entries {
157 let report = build_rule_report(rule, root);
158 for manifest in &report.matched {
159 for seed in &manifest.seeded {
160 out.push(PathRule::new(seed.clone()));
161 }
162 }
163 emit_report_warnings(&ext.name, &report);
164 }
165 out
166}
167
168#[must_use]
172pub fn check_manifest_entries(ext: &ExternalPluginDef, root: &Path) -> Vec<RuleReport> {
173 ext.manifest_entries
174 .iter()
175 .map(|rule| build_rule_report(rule, root))
176 .collect()
177}
178
179fn build_rule_report(rule: &ManifestEntryRule, root: &Path) -> RuleReport {
182 let mut report = RuleReport {
183 manifests: rule.manifests.clone(),
184 manifests_matched: Vec::new(),
185 matched: Vec::new(),
186 warnings: Vec::new(),
187 };
188
189 if rule.entries.is_empty() {
190 report.warnings.push(CheckWarning::glob(
191 WarningKind::EntriesEmpty,
192 &rule.manifests,
193 ));
194 return report;
195 }
196
197 let Ok(glob) = globset::Glob::new(&rule.manifests) else {
198 report.warnings.push(CheckWarning::glob(
201 WarningKind::ManifestsMatchedNone,
202 &rule.manifests,
203 ));
204 return report;
205 };
206 let matcher = glob.compile_matcher();
207
208 let referenced = referenced_field_paths(rule);
209 let mut resolved: BTreeMap<&str, bool> =
210 referenced.iter().map(|p| (p.as_str(), false)).collect();
211 let mut passed = 0usize;
212 let mut parsed = 0usize;
213
214 for file in discover_manifest_paths(root, &matcher) {
215 let rel_manifest = root_relative_forward_slash(&file, root)
216 .unwrap_or_else(|| file.to_string_lossy().replace('\\', "/"));
217 report.manifests_matched.push(rel_manifest.clone());
218
219 let manifest: Value = match std::fs::read_to_string(&file)
220 .ok()
221 .and_then(|source| fallow_config::jsonc::parse_to_value(&source).ok())
222 {
223 Some(value) => value,
224 None => {
225 report.warnings.push(CheckWarning::manifest(
228 WarningKind::ManifestParseFailed,
229 rel_manifest,
230 ));
231 continue;
232 }
233 };
234 parsed += 1;
235
236 let when_passed = when_matches(&manifest, &rule.when);
237 let mut seeded = Vec::new();
238 if when_passed {
239 passed += 1;
240 for path in &referenced {
241 if dotted_lookup(&manifest, path).is_some()
242 && let Some(flag) = resolved.get_mut(path.as_str())
243 {
244 *flag = true;
245 }
246 }
247 let (entries, mut entry_warnings) = seed_rule_entries(rule, &manifest, &file, root);
248 seeded = entries;
249 report.warnings.append(&mut entry_warnings);
250 }
251 report.matched.push(ManifestResult {
252 path: rel_manifest,
253 when_passed,
254 seeded,
255 });
256 }
257
258 report.warnings.extend(rule_level_warnings(
259 &rule.manifests,
260 report.manifests_matched.len(),
261 parsed,
262 passed,
263 &resolved,
264 ));
265
266 report.matched.sort_by(|a, b| a.path.cmp(&b.path));
271 report.warnings.sort_by(|a, b| {
272 a.kind
273 .as_kebab()
274 .cmp(b.kind.as_kebab())
275 .then_with(|| a.manifest.cmp(&b.manifest))
276 .then_with(|| a.entry.cmp(&b.entry))
277 .then_with(|| a.field_path.cmp(&b.field_path))
278 });
279 report
280}
281
282fn rule_level_warnings(
287 manifests: &str,
288 matched: usize,
289 parsed: usize,
290 passed: usize,
291 resolved: &BTreeMap<&str, bool>,
292) -> Vec<CheckWarning> {
293 let mut out = Vec::new();
294 if matched == 0 {
295 out.push(CheckWarning::glob(
296 WarningKind::ManifestsMatchedNone,
297 manifests,
298 ));
299 return out;
300 }
301 if parsed > 0 && passed == 0 {
305 out.push(CheckWarning::glob(WarningKind::WhenExcludedAll, manifests));
306 return out;
307 }
308 if passed == 0 {
309 return out;
310 }
311 for (path, was_resolved) in resolved {
312 if !was_resolved {
313 out.push(CheckWarning::field(
314 WarningKind::FieldPathUnresolved,
315 (*path).to_string(),
316 ));
317 }
318 }
319 out
320}
321
322fn seed_rule_entries(
325 rule: &ManifestEntryRule,
326 manifest: &Value,
327 manifest_path: &Path,
328 root: &Path,
329) -> (Vec<String>, Vec<CheckWarning>) {
330 let rel_manifest = root_relative_forward_slash(manifest_path, root);
331 let mut seeded = Vec::new();
332 let mut warnings = Vec::new();
333 for seed in &rule.entries {
334 if !when_matches(manifest, &seed.when) {
335 continue;
336 }
337 for concrete in expand_interpolations(&seed.path, manifest) {
338 match normalize_config_path(&concrete, manifest_path, root) {
339 Some(rel) => seeded.push(rel),
340 None => warnings.push(CheckWarning {
341 kind: WarningKind::EntryOutsideRoot,
342 glob: None,
343 field_path: None,
344 manifest: rel_manifest.clone(),
345 entry: Some(concrete),
346 }),
347 }
348 }
349 }
350 (seeded, warnings)
351}
352
353fn emit_report_warnings(plugin_name: &str, report: &RuleReport) {
355 for warning in &report.warnings {
356 match warning.kind {
357 WarningKind::EntriesEmpty => tracing::warn!(
358 "Plugin '{plugin_name}': manifestEntries rule for '{}' has an empty 'entries' \
359 list; it seeds nothing.",
360 report.manifests
361 ),
362 WarningKind::ManifestsMatchedNone => tracing::warn!(
363 "Plugin '{plugin_name}': manifestEntries 'manifests' glob '{}' matched no files. \
364 Check the glob and whether the manifests live under an ignored directory.",
365 report.manifests
366 ),
367 WarningKind::ManifestParseFailed => tracing::warn!(
368 "Plugin '{plugin_name}': manifestEntries skipped manifest '{}' (glob '{}') because \
369 it could not be read or parsed.",
370 warning.manifest.as_deref().unwrap_or(""),
371 report.manifests
372 ),
373 WarningKind::WhenExcludedAll => tracing::warn!(
374 "Plugin '{plugin_name}': manifestEntries 'when' gate excluded all matched \
375 manifest(s) for glob '{}'. No entries were seeded.",
376 report.manifests
377 ),
378 WarningKind::FieldPathUnresolved => tracing::warn!(
379 "Plugin '{plugin_name}': manifestEntries field path '{}' resolved in none of the \
380 gated manifest(s). Likely a typo in a 'when' key or a ${{...}} interpolation.",
381 warning.field_path.as_deref().unwrap_or("")
382 ),
383 WarningKind::EntryOutsideRoot => tracing::warn!(
384 "Plugin '{plugin_name}': manifestEntries entry '{}' (from manifest '{}') resolved \
385 outside the project root and was skipped.",
386 warning.entry.as_deref().unwrap_or(""),
387 warning.manifest.as_deref().unwrap_or("")
388 ),
389 WarningKind::SeededPathsMissing => {}
391 }
392 }
393}
394
395fn referenced_field_paths(rule: &ManifestEntryRule) -> Vec<String> {
398 let mut paths: Vec<String> = rule.when.keys().cloned().collect();
399 for seed in &rule.entries {
400 paths.extend(seed.when.keys().cloned());
401 paths.extend(interpolation_field_paths(&seed.path));
402 }
403 paths.sort();
404 paths.dedup();
405 paths
406}
407
408fn interpolation_field_paths(path: &str) -> Vec<String> {
410 let mut out = Vec::new();
411 let mut rest = path;
412 while let Some(start) = rest.find("${") {
413 let after = &rest[start + 2..];
414 if let Some(end) = after.find('}') {
415 out.push(after[..end].to_string());
416 rest = &after[end + 1..];
417 } else {
418 break;
419 }
420 }
421 out
422}
423
424fn expand_interpolations(path: &str, manifest: &Value) -> Vec<String> {
428 let Some(start) = path.find("${") else {
429 return vec![path.to_string()];
430 };
431 let prefix = &path[..start];
432 let after = &path[start + 2..];
433 let Some(end) = after.find('}') else {
434 return Vec::new();
436 };
437 let field = &after[..end];
438 let suffix = &after[end + 1..];
439
440 let mut out = Vec::new();
445 let tails = expand_interpolations(suffix, manifest);
446 for value in field_segment_values(manifest, field) {
447 for tail in &tails {
448 out.push(format!("{prefix}{value}{tail}"));
449 }
450 }
451 out
452}
453
454fn field_segment_values(manifest: &Value, field: &str) -> Vec<String> {
457 match dotted_lookup(manifest, field) {
458 Some(Value::String(s)) if !s.is_empty() => vec![s.clone()],
459 Some(Value::Number(n)) => vec![n.to_string()],
460 Some(Value::Array(items)) => items.iter().filter_map(scalar_segment).collect(),
461 _ => Vec::new(),
462 }
463}
464
465fn scalar_segment(value: &Value) -> Option<String> {
466 match value {
467 Value::String(s) if !s.is_empty() => Some(s.clone()),
468 Value::Number(n) => Some(n.to_string()),
469 _ => None,
470 }
471}
472
473fn when_matches(manifest: &Value, when: &BTreeMap<String, Value>) -> bool {
476 when.iter()
477 .all(|(path, expected)| dotted_lookup(manifest, path) == Some(expected))
478}
479
480fn dotted_lookup<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
482 let mut current = value;
483 for segment in path.split('.') {
484 current = current.get(segment)?;
485 }
486 Some(current)
487}
488
489fn discover_manifest_paths(root: &Path, matcher: &globset::GlobMatcher) -> Vec<PathBuf> {
493 let mut out = Vec::new();
494 let walker = ignore::WalkBuilder::new(root)
495 .hidden(false)
496 .git_ignore(true)
497 .git_global(true)
498 .git_exclude(true)
499 .filter_entry(|entry| entry.file_name() != "node_modules")
500 .build();
501 for entry in walker.flatten() {
502 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
505 continue;
506 }
507 let path = entry.path();
508 if let Some(rel) = root_relative_forward_slash(path, root)
509 && matcher.is_match(Path::new(&rel))
510 {
511 out.push(path.to_path_buf());
512 }
513 }
514 out.sort();
518 out
519}
520
521fn root_relative_forward_slash(file: &Path, root: &Path) -> Option<String> {
524 let rel = file.strip_prefix(root).ok()?;
525 Some(rel.to_string_lossy().replace('\\', "/"))
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use fallow_config::{EntryPointRole, ManifestFormat, ManifestSeedRule};
532
533 fn json(text: &str) -> Value {
534 serde_json::from_str(text).unwrap()
535 }
536
537 fn seed(path: &str, when: &[(&str, Value)]) -> ManifestSeedRule {
538 ManifestSeedRule {
539 path: path.to_string(),
540 when: when
541 .iter()
542 .map(|(k, v)| ((*k).to_string(), v.clone()))
543 .collect(),
544 }
545 }
546
547 #[test]
548 fn dotted_lookup_traverses_nested_fields() {
549 let m = json(r#"{"plugin": {"browser": true, "id": "actions"}}"#);
550 assert_eq!(
551 dotted_lookup(&m, "plugin.browser"),
552 Some(&Value::Bool(true))
553 );
554 assert_eq!(
555 dotted_lookup(&m, "plugin.id"),
556 Some(&Value::String("actions".into()))
557 );
558 assert_eq!(dotted_lookup(&m, "plugin.missing"), None);
559 assert_eq!(dotted_lookup(&m, "absent.field"), None);
560 }
561
562 #[test]
563 fn when_matches_is_strict_equality_and_presence_is_not_matched() {
564 let m = json(r#"{"type": "plugin", "plugin": {"browser": false}}"#);
565 let mut when = BTreeMap::new();
566 when.insert("type".to_string(), Value::String("plugin".into()));
567 assert!(when_matches(&m, &when));
568
569 let mut when_browser = BTreeMap::new();
572 when_browser.insert("plugin.browser".to_string(), Value::Bool(true));
573 assert!(!when_matches(&m, &when_browser));
574
575 assert!(when_matches(&m, &BTreeMap::new()));
577 }
578
579 #[test]
580 fn expand_interpolations_string_array_and_missing() {
581 let m = json(r#"{"plugin": {"extraPublicDirs": ["common", "types"], "id": "actions"}}"#);
582 assert_eq!(
584 expand_interpolations("${plugin.id}/index.ts", &m),
585 vec!["actions/index.ts"]
586 );
587 assert_eq!(
589 expand_interpolations("${plugin.extraPublicDirs}/index.{ts,tsx}", &m),
590 vec!["common/index.{ts,tsx}", "types/index.{ts,tsx}"]
591 );
592 assert!(expand_interpolations("${plugin.absent}/index.ts", &m).is_empty());
594 assert_eq!(
596 expand_interpolations("public/index.{ts,tsx}", &m),
597 vec!["public/index.{ts,tsx}"]
598 );
599 }
600
601 #[test]
602 fn evaluate_seeds_relative_to_manifest_dir_with_when_and_fanout() {
603 let dir = tempfile::tempdir().unwrap();
604 let root = dir.path();
605 let manifest_dir = root.join("x-pack/plugins/actions");
606 std::fs::create_dir_all(&manifest_dir).unwrap();
607 let manifest_path = manifest_dir.join("kibana.jsonc");
608 std::fs::write(
609 &manifest_path,
610 r#"{
611 // a real Kibana-shaped manifest
612 "type": "plugin",
613 "plugin": { "browser": true, "server": false, "extraPublicDirs": ["common"] },
614 }"#,
615 )
616 .unwrap();
617
618 let ext = ExternalPluginDef {
619 schema: None,
620 name: "kibana".to_string(),
621 detection: None,
622 enablers: vec![],
623 entry_points: vec![],
624 entry_point_role: EntryPointRole::Runtime,
625 manifest_entries: vec![ManifestEntryRule {
626 manifests: "**/kibana.jsonc".to_string(),
627 format: ManifestFormat::Jsonc,
628 when: BTreeMap::from([("type".to_string(), Value::String("plugin".into()))]),
629 entries: vec![
630 seed(
631 "public/index.{ts,tsx}",
632 &[("plugin.browser", Value::Bool(true))],
633 ),
634 seed(
635 "server/index.{ts,tsx}",
636 &[("plugin.server", Value::Bool(true))],
637 ),
638 seed("${plugin.extraPublicDirs}/index.{ts,tsx}", &[]),
639 ],
640 }],
641 config_patterns: vec![],
642 always_used: vec![],
643 tooling_dependencies: vec![],
644 used_exports: vec![],
645 used_class_members: vec![],
646 };
647
648 let rules = evaluate_manifest_entries(&ext, root);
649 let paths: Vec<&str> = rules.iter().map(|r| r.pattern.as_str()).collect();
650
651 assert!(paths.contains(&"x-pack/plugins/actions/public/index.{ts,tsx}"));
653 assert!(paths.contains(&"x-pack/plugins/actions/common/index.{ts,tsx}"));
654 assert!(
655 !paths.iter().any(|p| p.contains("server/index")),
656 "server:false must not seed the server entry, got {paths:?}"
657 );
658 }
659
660 fn plugin_with(rules: Vec<ManifestEntryRule>) -> ExternalPluginDef {
661 ExternalPluginDef {
662 schema: None,
663 name: "kibana".to_string(),
664 detection: None,
665 enablers: vec![],
666 entry_points: vec![],
667 entry_point_role: EntryPointRole::Runtime,
668 manifest_entries: rules,
669 config_patterns: vec![],
670 always_used: vec![],
671 tooling_dependencies: vec![],
672 used_exports: vec![],
673 used_class_members: vec![],
674 }
675 }
676
677 fn rule(
678 manifests: &str,
679 when: &[(&str, Value)],
680 entries: Vec<ManifestSeedRule>,
681 ) -> ManifestEntryRule {
682 ManifestEntryRule {
683 manifests: manifests.to_string(),
684 format: ManifestFormat::Jsonc,
685 when: when
686 .iter()
687 .map(|(k, v)| ((*k).to_string(), v.clone()))
688 .collect(),
689 entries,
690 }
691 }
692
693 fn write_manifest(root: &Path, rel: &str, body: &str) {
694 let p = root.join(rel);
695 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
696 std::fs::write(p, body).unwrap();
697 }
698
699 fn kinds(reports: &[RuleReport]) -> Vec<WarningKind> {
700 reports
701 .iter()
702 .flat_map(|r| r.warnings.iter().map(|w| w.kind))
703 .collect()
704 }
705
706 #[test]
707 fn check_reports_matched_manifests_when_gate_and_seeded_entries() {
708 let dir = tempfile::tempdir().unwrap();
709 let root = dir.path();
710 write_manifest(
711 root,
712 "plugins/alpha/kibana.jsonc",
713 r#"{"type":"plugin","plugin":{"browser":true,"server":true}}"#,
714 );
715 write_manifest(
716 root,
717 "plugins/beta/kibana.jsonc",
718 r#"{"type":"plugin","plugin":{"browser":true,"server":false}}"#,
719 );
720 let ext = plugin_with(vec![rule(
721 "**/kibana.jsonc",
722 &[("type", Value::String("plugin".into()))],
723 vec![
724 seed(
725 "public/index.{ts,tsx}",
726 &[("plugin.browser", Value::Bool(true))],
727 ),
728 seed(
729 "server/index.{ts,tsx}",
730 &[("plugin.server", Value::Bool(true))],
731 ),
732 ],
733 )]);
734
735 let reports = check_manifest_entries(&ext, root);
736 assert_eq!(reports.len(), 1);
737 let report = &reports[0];
738 assert!(
739 report.warnings.is_empty(),
740 "clean plugin, got {:?}",
741 report.warnings
742 );
743 assert_eq!(
745 report.manifests_matched,
746 vec![
747 "plugins/alpha/kibana.jsonc".to_string(),
748 "plugins/beta/kibana.jsonc".to_string()
749 ]
750 );
751
752 let beta = report
753 .matched
754 .iter()
755 .find(|m| m.path == "plugins/beta/kibana.jsonc")
756 .expect("beta matched");
757 assert!(beta.when_passed);
758 assert!(beta.seeded.iter().any(|s| s.contains("beta/public/index")));
759 assert!(
760 !beta.seeded.iter().any(|s| s.contains("server/index")),
761 "beta server:false must not seed the server entry, got {:?}",
762 beta.seeded
763 );
764 }
765
766 #[test]
767 fn check_warns_manifests_matched_none() {
768 let dir = tempfile::tempdir().unwrap();
769 let ext = plugin_with(vec![rule(
770 "**/nonexistent.jsonc",
771 &[],
772 vec![seed("public/index.ts", &[])],
773 )]);
774 let reports = check_manifest_entries(&ext, dir.path());
775 assert!(kinds(&reports).contains(&WarningKind::ManifestsMatchedNone));
776 assert_eq!(
777 reports[0].warnings[0].glob.as_deref(),
778 Some("**/nonexistent.jsonc")
779 );
780 }
781
782 #[test]
783 fn check_warns_field_path_unresolved_on_typo() {
784 let dir = tempfile::tempdir().unwrap();
785 let root = dir.path();
786 write_manifest(
787 root,
788 "plugins/alpha/kibana.jsonc",
789 r#"{"type":"plugin","plugin":{"browser":true}}"#,
790 );
791 let ext = plugin_with(vec![rule(
792 "**/kibana.jsonc",
793 &[("type", Value::String("plugin".into()))],
794 vec![seed("${plugin.extarPublicDirs}/index.ts", &[])],
796 )]);
797 let reports = check_manifest_entries(&ext, root);
798 let warn = reports[0]
799 .warnings
800 .iter()
801 .find(|w| w.kind == WarningKind::FieldPathUnresolved)
802 .expect("field-path-unresolved warning");
803 assert_eq!(warn.field_path.as_deref(), Some("plugin.extarPublicDirs"));
804 }
805
806 #[test]
807 fn check_warns_when_excluded_all() {
808 let dir = tempfile::tempdir().unwrap();
809 let root = dir.path();
810 write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"package"}"#);
811 let ext = plugin_with(vec![rule(
812 "**/kibana.jsonc",
813 &[("type", Value::String("plugin".into()))],
814 vec![seed("public/index.ts", &[])],
815 )]);
816 let reports = check_manifest_entries(&ext, root);
817 assert!(kinds(&reports).contains(&WarningKind::WhenExcludedAll));
818 }
819
820 #[test]
821 fn check_warns_manifest_parse_failed_per_file() {
822 let dir = tempfile::tempdir().unwrap();
823 let root = dir.path();
824 write_manifest(root, "plugins/good/kibana.jsonc", r#"{"type":"plugin"}"#);
825 write_manifest(root, "plugins/bad/kibana.jsonc", "{ this is not valid json");
826 let ext = plugin_with(vec![rule(
827 "**/kibana.jsonc",
828 &[("type", Value::String("plugin".into()))],
829 vec![seed("public/index.ts", &[])],
830 )]);
831 let reports = check_manifest_entries(&ext, root);
832 let warn = reports[0]
833 .warnings
834 .iter()
835 .find(|w| w.kind == WarningKind::ManifestParseFailed)
836 .expect("manifest-parse-failed warning");
837 assert_eq!(warn.manifest.as_deref(), Some("plugins/bad/kibana.jsonc"));
839 }
840
841 #[test]
842 fn check_output_is_deterministic_across_walk_order() {
843 let dir = tempfile::tempdir().unwrap();
844 let root = dir.path();
845 for name in ["mmm", "aaa", "zzz", "ccc"] {
847 write_manifest(
848 root,
849 &format!("plugins/{name}/kibana.jsonc"),
850 r#"{"type":"plugin"}"#,
851 );
852 }
853 let ext = plugin_with(vec![rule(
854 "**/kibana.jsonc",
855 &[("type", Value::String("plugin".into()))],
856 vec![seed("../../../../escape/index.ts", &[])],
858 )]);
859 let reports = check_manifest_entries(&ext, root);
860 let r = &reports[0];
861 let mut sorted = r.manifests_matched.clone();
863 sorted.sort();
864 assert_eq!(
865 r.manifests_matched, sorted,
866 "manifests_matched must be sorted"
867 );
868 let warn_manifests: Vec<&str> = r
869 .warnings
870 .iter()
871 .filter_map(|w| w.manifest.as_deref())
872 .collect();
873 let mut sorted_w = warn_manifests.clone();
874 sorted_w.sort_unstable();
875 assert_eq!(
876 warn_manifests, sorted_w,
877 "entry-outside-root warnings must be sorted"
878 );
879 }
880
881 #[test]
882 fn check_warns_entry_outside_root() {
883 let dir = tempfile::tempdir().unwrap();
884 let root = dir.path();
885 write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"plugin"}"#);
886 let ext = plugin_with(vec![rule(
887 "**/kibana.jsonc",
888 &[("type", Value::String("plugin".into()))],
889 vec![seed("../../../../escape/index.ts", &[])],
891 )]);
892 let reports = check_manifest_entries(&ext, root);
893 let warn = reports[0]
894 .warnings
895 .iter()
896 .find(|w| w.kind == WarningKind::EntryOutsideRoot)
897 .expect("entry-outside-root warning");
898 assert!(warn.entry.as_deref().is_some_and(|e| e.contains("escape")));
899 assert_eq!(warn.manifest.as_deref(), Some("plugins/alpha/kibana.jsonc"));
900 }
901}