use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use fallow_config::{
ExternalPluginDef, ManifestCondition, ManifestEntryRule, ManifestFieldPath,
ManifestFieldSegment, ManifestFormat, ManifestPathPart, ManifestPathTemplate,
};
use serde_json::Value;
use super::PathRule;
use super::config_parser::normalize_config_path;
const MAX_MANIFEST_FIELD_VALUES: usize = 1_024;
const MAX_MANIFEST_ENTRY_EXPANSIONS: usize = 4_096;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WarningKind {
ManifestsMatchedNone,
WhenExcludedAll,
FieldPathUnresolved,
EntriesEmpty,
ManifestParseFailed,
FieldValuesLimitExceeded,
EntryExpansionLimitExceeded,
EntryOutsideRoot,
SeededPathsMissing,
}
impl WarningKind {
#[must_use]
pub fn as_kebab(self) -> &'static str {
match self {
Self::ManifestsMatchedNone => "manifests-matched-none",
Self::WhenExcludedAll => "when-excluded-all",
Self::FieldPathUnresolved => "field-path-unresolved",
Self::EntriesEmpty => "entries-empty",
Self::ManifestParseFailed => "manifest-parse-failed",
Self::FieldValuesLimitExceeded => "field-values-limit-exceeded",
Self::EntryExpansionLimitExceeded => "entry-expansion-limit-exceeded",
Self::EntryOutsideRoot => "entry-outside-root",
Self::SeededPathsMissing => "seeded-paths-missing",
}
}
#[must_use]
pub fn expansion_limit(self) -> Option<usize> {
match self {
Self::FieldValuesLimitExceeded => Some(MAX_MANIFEST_FIELD_VALUES),
Self::EntryExpansionLimitExceeded => Some(MAX_MANIFEST_ENTRY_EXPANSIONS),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckWarning {
pub kind: WarningKind,
pub glob: Option<String>,
pub field_path: Option<String>,
pub manifest: Option<String>,
pub entry: Option<String>,
}
impl CheckWarning {
fn glob(kind: WarningKind, glob: &str) -> Self {
Self {
kind,
glob: Some(glob.to_string()),
field_path: None,
manifest: None,
entry: None,
}
}
fn field(kind: WarningKind, field_path: String) -> Self {
Self {
kind,
glob: None,
field_path: Some(field_path),
manifest: None,
entry: None,
}
}
fn manifest(kind: WarningKind, manifest: String) -> Self {
Self {
kind,
glob: None,
field_path: None,
manifest: Some(manifest),
entry: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ExpansionError {
FieldValues { field_path: String },
EntryPaths { template: String },
}
impl ExpansionError {
fn into_warning(self, manifest: Option<String>) -> CheckWarning {
match self {
Self::FieldValues { field_path } => CheckWarning {
kind: WarningKind::FieldValuesLimitExceeded,
glob: None,
field_path: Some(field_path),
manifest,
entry: None,
},
Self::EntryPaths { template } => CheckWarning {
kind: WarningKind::EntryExpansionLimitExceeded,
glob: None,
field_path: None,
manifest,
entry: Some(template),
},
}
}
}
#[derive(Debug, Clone)]
pub struct ManifestResult {
pub path: String,
pub when_passed: bool,
pub seeded: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct RuleReport {
pub manifests: String,
pub manifests_matched: Vec<String>,
pub matched: Vec<ManifestResult>,
pub warnings: Vec<CheckWarning>,
}
#[must_use]
pub(crate) fn evaluate_manifest_entries(ext: &ExternalPluginDef, root: &Path) -> Vec<PathRule> {
let mut out = Vec::new();
for rule in &ext.manifest_entries {
let report = build_rule_report(rule, root);
for manifest in &report.matched {
for seed in &manifest.seeded {
out.push(PathRule::new(seed.clone()));
}
}
emit_report_warnings(&ext.name, &report);
}
out
}
#[must_use]
pub fn check_manifest_entries(ext: &ExternalPluginDef, root: &Path) -> Vec<RuleReport> {
ext.manifest_entries
.iter()
.map(|rule| build_rule_report(rule, root))
.collect()
}
fn build_rule_report(rule: &ManifestEntryRule, root: &Path) -> RuleReport {
let mut report = RuleReport {
manifests: rule.manifests.clone(),
manifests_matched: Vec::new(),
matched: Vec::new(),
warnings: Vec::new(),
};
if rule.entries.is_empty() {
report.warnings.push(CheckWarning::glob(
WarningKind::EntriesEmpty,
&rule.manifests,
));
return report;
}
let Ok(glob) = globset::Glob::new(&rule.manifests) else {
report.warnings.push(CheckWarning::glob(
WarningKind::ManifestsMatchedNone,
&rule.manifests,
));
return report;
};
let matcher = glob.compile_matcher();
let referenced = referenced_field_paths(rule);
let mut resolved: BTreeMap<&str, bool> =
referenced.iter().map(|p| (p.as_str(), false)).collect();
let mut passed = 0usize;
let mut parsed = 0usize;
let mut gate_errors = 0usize;
for file in discover_manifest_paths(root, &matcher) {
let rel_manifest = root_relative_forward_slash(&file, root)
.unwrap_or_else(|| file.to_string_lossy().replace('\\', "/"));
report.manifests_matched.push(rel_manifest.clone());
let manifest: Value = match std::fs::read_to_string(&file)
.ok()
.and_then(|source| parse_manifest(&source, rule.format))
{
Some(value) => value,
None => {
report.warnings.push(CheckWarning::manifest(
WarningKind::ManifestParseFailed,
rel_manifest,
));
continue;
}
};
parsed += 1;
let when_passed = match when_matches(&manifest, &rule.when) {
Ok(passed) => passed,
Err(error) => {
gate_errors += 1;
report
.warnings
.push(error.into_warning(Some(rel_manifest.clone())));
false
}
};
let mut seeded = Vec::new();
if when_passed {
passed += 1;
for path in &referenced {
let path_resolved = match field_values(&manifest, path) {
Ok(values) => !values.is_empty(),
Err(_) => true,
};
if path_resolved && let Some(flag) = resolved.get_mut(path.as_str()) {
*flag = true;
}
}
let (entries, mut entry_warnings) = seed_rule_entries(rule, &manifest, &file, root);
seeded = entries;
report.warnings.append(&mut entry_warnings);
}
report.matched.push(ManifestResult {
path: rel_manifest,
when_passed,
seeded,
});
}
report.warnings.extend(rule_level_warnings(
&rule.manifests,
report.manifests_matched.len(),
parsed,
passed,
gate_errors,
&resolved,
));
report.matched.sort_by(|a, b| a.path.cmp(&b.path));
report.warnings.sort_by(|a, b| {
a.kind
.as_kebab()
.cmp(b.kind.as_kebab())
.then_with(|| a.manifest.cmp(&b.manifest))
.then_with(|| a.entry.cmp(&b.entry))
.then_with(|| a.field_path.cmp(&b.field_path))
});
report.warnings.dedup();
report
}
fn parse_manifest(source: &str, format: ManifestFormat) -> Option<Value> {
match format {
ManifestFormat::Jsonc => fallow_config::jsonc::parse_to_value(source).ok(),
ManifestFormat::Json => serde_json::from_str(source).ok(),
}
}
fn rule_level_warnings(
manifests: &str,
matched: usize,
parsed: usize,
passed: usize,
gate_errors: usize,
resolved: &BTreeMap<&str, bool>,
) -> Vec<CheckWarning> {
let mut out = Vec::new();
if matched == 0 {
out.push(CheckWarning::glob(
WarningKind::ManifestsMatchedNone,
manifests,
));
return out;
}
if parsed > 0 && passed == 0 && gate_errors == 0 {
out.push(CheckWarning::glob(WarningKind::WhenExcludedAll, manifests));
return out;
}
if passed == 0 {
return out;
}
for (path, was_resolved) in resolved {
if !was_resolved {
out.push(CheckWarning::field(
WarningKind::FieldPathUnresolved,
(*path).to_string(),
));
}
}
out
}
fn seed_rule_entries(
rule: &ManifestEntryRule,
manifest: &Value,
manifest_path: &Path,
root: &Path,
) -> (Vec<String>, Vec<CheckWarning>) {
let rel_manifest = root_relative_forward_slash(manifest_path, root);
let mut seeded = Vec::new();
let mut warnings = Vec::new();
for seed in &rule.entries {
match when_matches(manifest, &seed.when) {
Ok(true) => {}
Ok(false) => continue,
Err(error) => {
warnings.push(error.into_warning(rel_manifest.clone()));
continue;
}
}
let concretes = match expand_interpolations(&seed.path, manifest) {
Ok(concretes) => concretes,
Err(error) => {
warnings.push(error.into_warning(rel_manifest.clone()));
continue;
}
};
for concrete in concretes {
match normalize_config_path(&concrete, manifest_path, root) {
Some(rel) => seeded.push(rel),
None => warnings.push(CheckWarning {
kind: WarningKind::EntryOutsideRoot,
glob: None,
field_path: None,
manifest: rel_manifest.clone(),
entry: Some(concrete),
}),
}
}
}
(seeded, warnings)
}
fn emit_report_warnings(plugin_name: &str, report: &RuleReport) {
for warning in &report.warnings {
match warning.kind {
WarningKind::EntriesEmpty => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries rule for '{}' has an empty 'entries' \
list; it seeds nothing.",
report.manifests
),
WarningKind::ManifestsMatchedNone => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries 'manifests' glob '{}' matched no files. \
Check the glob and whether the manifests live under an ignored directory.",
report.manifests
),
WarningKind::ManifestParseFailed => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries skipped manifest '{}' (glob '{}') because \
it could not be read or parsed using the rule's declared format.",
warning.manifest.as_deref().unwrap_or(""),
report.manifests
),
WarningKind::FieldValuesLimitExceeded => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries field path '{}' in manifest '{}' exceeded \
the traversal value limit of {}. The affected gate or template was skipped without \
partial seeding.",
warning.field_path.as_deref().unwrap_or(""),
warning.manifest.as_deref().unwrap_or(""),
warning
.kind
.expansion_limit()
.unwrap_or(MAX_MANIFEST_FIELD_VALUES)
),
WarningKind::EntryExpansionLimitExceeded => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries template '{}' in manifest '{}' exceeded \
the concrete entry limit of {}. No entries were seeded from that template.",
warning.entry.as_deref().unwrap_or(""),
warning.manifest.as_deref().unwrap_or(""),
warning
.kind
.expansion_limit()
.unwrap_or(MAX_MANIFEST_ENTRY_EXPANSIONS)
),
WarningKind::WhenExcludedAll => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries 'when' gate excluded all matched \
manifest(s) for glob '{}'. No entries were seeded.",
report.manifests
),
WarningKind::FieldPathUnresolved => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries field path '{}' resolved in none of the \
gated manifest(s). Likely a typo in a 'when' key or a ${{...}} interpolation.",
warning.field_path.as_deref().unwrap_or("")
),
WarningKind::EntryOutsideRoot => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries entry '{}' (from manifest '{}') resolved \
outside the project root and was skipped.",
warning.entry.as_deref().unwrap_or(""),
warning.manifest.as_deref().unwrap_or("")
),
WarningKind::SeededPathsMissing => {}
}
}
}
fn referenced_field_paths(rule: &ManifestEntryRule) -> Vec<ManifestFieldPath> {
let mut paths: Vec<ManifestFieldPath> = rule
.when
.iter()
.filter(|(_, condition)| condition_requires_present_value(condition))
.map(|(path, _)| path.clone())
.collect();
for seed in &rule.entries {
paths.extend(
seed.when
.iter()
.filter(|(_, condition)| condition_requires_present_value(condition))
.map(|(path, _)| path.clone()),
);
paths.extend(seed.path.parts().iter().filter_map(|part| match part {
ManifestPathPart::Field(path) => Some(path.clone()),
ManifestPathPart::Literal(_) => None,
}));
}
paths.sort();
paths.dedup();
paths
}
fn expand_interpolations(
path: &ManifestPathTemplate,
manifest: &Value,
) -> Result<Vec<String>, ExpansionError> {
let mut expanded = vec![String::new()];
for part in path.parts() {
match part {
ManifestPathPart::Literal(literal) => {
for value in &mut expanded {
value.push_str(literal);
}
}
ManifestPathPart::Field(field) => {
let values = field_segment_values(manifest, field)?;
if values.is_empty() {
return Ok(Vec::new());
}
let Some(next_len) = expanded.len().checked_mul(values.len()) else {
return Err(ExpansionError::EntryPaths {
template: path.as_str().to_string(),
});
};
if next_len > MAX_MANIFEST_ENTRY_EXPANSIONS {
return Err(ExpansionError::EntryPaths {
template: path.as_str().to_string(),
});
}
let mut next = Vec::with_capacity(next_len);
for prefix in &expanded {
for value in &values {
let mut concrete = String::with_capacity(prefix.len() + value.len());
concrete.push_str(prefix);
concrete.push_str(value);
next.push(concrete);
}
}
expanded = next;
}
}
}
Ok(expanded)
}
fn field_segment_values(
manifest: &Value,
field: &ManifestFieldPath,
) -> Result<Vec<String>, ExpansionError> {
let mut values = Vec::new();
for value in field_values(manifest, field)? {
match value {
Value::Array(items) => {
for item in items.iter().filter_map(scalar_segment) {
push_field_segment(&mut values, item, field)?;
}
}
value => {
if let Some(segment) = scalar_segment(value) {
push_field_segment(&mut values, segment, field)?;
}
}
}
}
Ok(values)
}
fn push_field_segment(
values: &mut Vec<String>,
value: String,
field: &ManifestFieldPath,
) -> Result<(), ExpansionError> {
if values.len() == MAX_MANIFEST_FIELD_VALUES {
return Err(ExpansionError::FieldValues {
field_path: field.as_str().to_string(),
});
}
values.push(value);
Ok(())
}
fn scalar_segment(value: &Value) -> Option<String> {
match value {
Value::String(s) if !s.is_empty() => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn when_matches(
manifest: &Value,
when: &BTreeMap<ManifestFieldPath, ManifestCondition>,
) -> Result<bool, ExpansionError> {
for (path, condition) in when {
let values = field_values(manifest, path)?;
let matches = match condition {
ManifestCondition::Equals(expected) => values.contains(&expected),
ManifestCondition::Exists(predicate) => values.is_empty() != predicate.exists,
};
if !matches {
return Ok(false);
}
}
Ok(true)
}
fn condition_requires_present_value(condition: &ManifestCondition) -> bool {
match condition {
ManifestCondition::Equals(_) => true,
ManifestCondition::Exists(predicate) => predicate.exists,
}
}
fn field_values<'a>(
value: &'a Value,
path: &ManifestFieldPath,
) -> Result<Vec<&'a Value>, ExpansionError> {
let mut current = vec![value];
for segment in path.segments() {
let mut next = Vec::new();
for value in current {
match segment {
ManifestFieldSegment::Key(key) => {
if let Some(child) = value.get(key) {
push_field_value(&mut next, child, path)?;
}
}
ManifestFieldSegment::Each => {
if let Value::Array(items) = value {
for item in items {
push_field_value(&mut next, item, path)?;
}
}
}
}
}
current = next;
}
Ok(current)
}
fn push_field_value<'a>(
values: &mut Vec<&'a Value>,
value: &'a Value,
path: &ManifestFieldPath,
) -> Result<(), ExpansionError> {
if values.len() == MAX_MANIFEST_FIELD_VALUES {
return Err(ExpansionError::FieldValues {
field_path: path.as_str().to_string(),
});
}
values.push(value);
Ok(())
}
fn discover_manifest_paths(root: &Path, matcher: &globset::GlobMatcher) -> Vec<PathBuf> {
let mut out = Vec::new();
let canonical_root = root.canonicalize().ok();
let walker = ignore::WalkBuilder::new(root)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.filter_entry(|entry| entry.file_name() != "node_modules")
.build();
for entry in walker.flatten() {
let Some(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
continue;
}
let path = entry.path();
if file_type.is_symlink()
&& !is_contained_regular_file_symlink(path, canonical_root.as_deref())
{
tracing::debug!(
path = %path.display(),
"skipping manifest symlink with a broken, non-file, or outside-root target"
);
continue;
}
if let Some(rel) = root_relative_forward_slash(path, root)
&& matcher.is_match(Path::new(&rel))
{
out.push(path.to_path_buf());
}
}
out.sort();
out
}
fn is_contained_regular_file_symlink(path: &Path, canonical_root: Option<&Path>) -> bool {
let Some(root) = canonical_root else {
return false;
};
let Ok(target) = path.canonicalize() else {
return false;
};
target.starts_with(root) && target.metadata().is_ok_and(|metadata| metadata.is_file())
}
fn root_relative_forward_slash(file: &Path, root: &Path) -> Option<String> {
let rel = file.strip_prefix(root).ok()?;
Some(rel.to_string_lossy().replace('\\', "/"))
}
#[cfg(test)]
mod tests {
use super::*;
use fallow_config::{
EntryPointRole, ManifestExistsPredicate, ManifestFormat, ManifestSeedRule,
};
fn json(text: &str) -> Value {
serde_json::from_str(text).unwrap()
}
fn seed(path: &str, when: &[(&str, Value)]) -> ManifestSeedRule {
ManifestSeedRule {
path: path.parse().unwrap(),
when: conditions(when),
}
}
fn field(path: &str) -> ManifestFieldPath {
path.parse().unwrap()
}
fn template(path: &str) -> ManifestPathTemplate {
path.parse().unwrap()
}
fn conditions(when: &[(&str, Value)]) -> BTreeMap<ManifestFieldPath, ManifestCondition> {
when.iter()
.map(|(path, expected)| (field(path), ManifestCondition::Equals(expected.clone())))
.collect()
}
fn exists(path: &str, expected: bool) -> (ManifestFieldPath, ManifestCondition) {
(
field(path),
ManifestCondition::Exists(ManifestExistsPredicate { exists: expected }),
)
}
#[test]
fn field_values_traverse_nested_fields_and_object_arrays() {
let m = json(r#"{"plugin": {"browser": true, "id": "actions"}}"#);
assert_eq!(
field_values(&m, &field("plugin.browser")).unwrap(),
vec![&Value::Bool(true)]
);
assert_eq!(
field_values(&m, &field("plugin.id")).unwrap(),
vec![&Value::String("actions".into())]
);
assert!(
field_values(&m, &field("plugin.missing"))
.unwrap()
.is_empty()
);
assert!(field_values(&m, &field("absent.field")).unwrap().is_empty());
let m = json(
r#"{"content_scripts":[{"js":["a.js","b.js"]},null,{"js":["c.js"]},"invalid",{"css":[]}]}"#,
);
assert_eq!(
field_segment_values(&m, &field("content_scripts[*].js")).unwrap(),
vec!["a.js", "b.js", "c.js"]
);
let nested = json(
r#"{"groups":[{"entries":[{"path":"a.js"},{"path":"b.js"}]},{"entries":[{"path":"c.js"}]}]}"#,
);
assert_eq!(
field_segment_values(&nested, &field("groups[*].entries[*].path")).unwrap(),
vec!["a.js", "b.js", "c.js"]
);
}
#[test]
fn when_matches_is_strict_equality_and_presence_is_not_matched() {
let m = json(r#"{"type": "plugin", "plugin": {"browser": false}}"#);
let mut when = BTreeMap::new();
when.insert(
field("type"),
ManifestCondition::Equals(Value::String("plugin".into())),
);
assert!(when_matches(&m, &when).unwrap());
let mut when_browser = BTreeMap::new();
when_browser.insert(
field("plugin.browser"),
ManifestCondition::Equals(Value::Bool(true)),
);
assert!(!when_matches(&m, &when_browser).unwrap());
assert!(when_matches(&m, &BTreeMap::new()).unwrap());
let manifest = json(r#"{"plugins":[{"kind":"worker"},{"kind":"browser"}]}"#);
let wildcard = conditions(&[("plugins[*].kind", Value::String("browser".into()))]);
assert!(when_matches(&manifest, &wildcard).unwrap());
let structured = json(r#"{"array":["worker"],"object":{"kind":"browser"}}"#);
let structured_when = BTreeMap::from([
(
field("array"),
ManifestCondition::Equals(json(r#"["worker"]"#)),
),
(
field("object"),
ManifestCondition::Equals(json(r#"{"kind":"browser"}"#)),
),
]);
assert!(when_matches(&structured, &structured_when).unwrap());
}
#[test]
fn exists_conditions_test_presence_without_truthiness() {
let manifest = json(
r#"{"presentFalse":false,"presentNull":null,"presentEmpty":"","presentObject":{},"items":[]}"#,
);
for path in [
"presentFalse",
"presentNull",
"presentEmpty",
"presentObject",
"items",
] {
assert!(
when_matches(&manifest, &BTreeMap::from([exists(path, true)])).unwrap(),
"{path} is present regardless of its value"
);
}
assert!(when_matches(&manifest, &BTreeMap::from([exists("missing", false)])).unwrap());
assert!(!when_matches(&manifest, &BTreeMap::from([exists("missing", true)])).unwrap());
assert!(
when_matches(
&manifest,
&BTreeMap::from([exists("items[*].value", false)])
)
.unwrap()
);
}
#[test]
fn exists_false_can_gate_a_rule_without_an_unresolved_path_warning() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_manifest(root, "plugins/alpha/manifest.json", r#"{"name":"alpha"}"#);
let mut manifest_rule = rule("**/manifest.json", &[], vec![seed("index.ts", &[])]);
manifest_rule.when = BTreeMap::from([exists("main", false)]);
let reports = check_manifest_entries(&plugin_with(vec![manifest_rule]), root);
assert!(reports[0].warnings.is_empty());
assert!(reports[0].matched[0].when_passed);
assert_eq!(reports[0].matched[0].seeded, vec!["plugins/alpha/index.ts"]);
}
#[test]
fn parse_manifest_honors_the_declared_format() {
let jsonc_only = r#"{
// JSONC comment
"type": "plugin",
}"#;
assert!(parse_manifest(jsonc_only, ManifestFormat::Jsonc).is_some());
assert!(parse_manifest(jsonc_only, ManifestFormat::Json).is_none());
assert!(parse_manifest(r#"{"type":"plugin"}"#, ManifestFormat::Json).is_some());
}
#[test]
fn expand_interpolations_string_array_and_missing() {
let m = json(r#"{"plugin": {"extraPublicDirs": ["common", "types"], "id": "actions"}}"#);
assert_eq!(
expand_interpolations(&template("${plugin.id}/index.ts"), &m).unwrap(),
vec!["actions/index.ts"]
);
assert_eq!(
expand_interpolations(&template("${plugin.extraPublicDirs}/index.{ts,tsx}"), &m)
.unwrap(),
vec!["common/index.{ts,tsx}", "types/index.{ts,tsx}"]
);
assert!(
expand_interpolations(&template("${plugin.absent}/index.ts"), &m)
.unwrap()
.is_empty()
);
assert_eq!(
expand_interpolations(&template("public/index.{ts,tsx}"), &m).unwrap(),
vec!["public/index.{ts,tsx}"]
);
}
#[test]
fn interpolation_limits_are_explicit_errors() {
let too_many: Vec<Value> = (0..=MAX_MANIFEST_FIELD_VALUES)
.map(|index| Value::String(index.to_string()))
.collect();
let manifest = serde_json::json!({ "values": too_many });
assert_eq!(
expand_interpolations(&template("${values}/index.ts"), &manifest),
Err(ExpansionError::FieldValues {
field_path: "values".to_string(),
})
);
let factors = (0..65)
.map(|index| Value::String(index.to_string()))
.collect::<Vec<_>>();
let manifest = serde_json::json!({ "left": factors, "right": factors });
assert_eq!(
expand_interpolations(&template("${left}/${right}/index.ts"), &manifest),
Err(ExpansionError::EntryPaths {
template: "${left}/${right}/index.ts".to_string(),
})
);
}
#[test]
fn evaluate_seeds_relative_to_manifest_dir_with_when_and_fanout() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let manifest_dir = root.join("x-pack/plugins/actions");
std::fs::create_dir_all(&manifest_dir).unwrap();
let manifest_path = manifest_dir.join("kibana.jsonc");
std::fs::write(
&manifest_path,
r#"{
// a real Kibana-shaped manifest
"type": "plugin",
"plugin": { "browser": true, "server": false, "extraPublicDirs": ["common"] },
}"#,
)
.unwrap();
let ext = ExternalPluginDef {
schema: None,
name: "kibana".to_string(),
detection: None,
enablers: vec![],
entry_points: vec![],
entry_point_role: EntryPointRole::Runtime,
manifest_entries: vec![ManifestEntryRule {
manifests: "**/kibana.jsonc".to_string(),
format: ManifestFormat::Jsonc,
when: conditions(&[("type", Value::String("plugin".into()))]),
entries: vec![
seed(
"public/index.{ts,tsx}",
&[("plugin.browser", Value::Bool(true))],
),
seed(
"server/index.{ts,tsx}",
&[("plugin.server", Value::Bool(true))],
),
seed("${plugin.extraPublicDirs}/index.{ts,tsx}", &[]),
],
}],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
};
let rules = evaluate_manifest_entries(&ext, root);
let paths: Vec<&str> = rules.iter().map(|r| r.pattern.as_str()).collect();
assert!(paths.contains(&"x-pack/plugins/actions/public/index.{ts,tsx}"));
assert!(paths.contains(&"x-pack/plugins/actions/common/index.{ts,tsx}"));
assert!(
!paths.iter().any(|p| p.contains("server/index")),
"server:false must not seed the server entry, got {paths:?}"
);
}
#[test]
fn evaluate_fans_out_over_object_array_fields() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_manifest(
root,
"extension/manifest.json",
r#"{
"manifest_version": 3,
"content_scripts": [
{ "matches": ["https://a.example/*"], "js": ["content/a.js", "content/b.js"] },
{ "matches": ["https://b.example/*"], "js": ["content/c.js"] }
]
}"#,
);
let ext = plugin_with(vec![rule(
"**/manifest.json",
&[("manifest_version", Value::Number(3.into()))],
vec![seed("${content_scripts[*].js}", &[])],
)]);
let reports = check_manifest_entries(&ext, root);
assert!(reports[0].warnings.is_empty());
assert_eq!(
reports[0].matched[0].seeded,
vec![
"extension/content/a.js",
"extension/content/b.js",
"extension/content/c.js",
]
);
}
fn plugin_with(rules: Vec<ManifestEntryRule>) -> ExternalPluginDef {
ExternalPluginDef {
schema: None,
name: "kibana".to_string(),
detection: None,
enablers: vec![],
entry_points: vec![],
entry_point_role: EntryPointRole::Runtime,
manifest_entries: rules,
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
}
}
fn rule(
manifests: &str,
when: &[(&str, Value)],
entries: Vec<ManifestSeedRule>,
) -> ManifestEntryRule {
ManifestEntryRule {
manifests: manifests.to_string(),
format: ManifestFormat::Jsonc,
when: conditions(when),
entries,
}
}
fn write_manifest(root: &Path, rel: &str, body: &str) {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
}
#[cfg(unix)]
fn symlink_file(target: &Path, link: &Path) {
std::os::unix::fs::symlink(target, link).expect("create file symlink");
}
#[cfg(windows)]
fn symlink_file(target: &Path, link: &Path) {
std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
}
#[test]
fn manifest_symlinks_must_target_regular_files_inside_root() {
let dir = tempfile::tempdir().expect("create project");
let outside = tempfile::tempdir().expect("create outside dir");
let root = dir.path();
let targets = root.join("targets");
let plugins = root.join("plugins");
std::fs::create_dir_all(&targets).unwrap();
std::fs::create_dir_all(&plugins).unwrap();
std::fs::write(targets.join("inside.jsonc"), r#"{"type":"plugin"}"#).unwrap();
std::fs::write(outside.path().join("outside.jsonc"), r#"{"type":"plugin"}"#).unwrap();
symlink_file(
&targets.join("inside.jsonc"),
&plugins.join("inside-kibana.jsonc"),
);
symlink_file(
&outside.path().join("outside.jsonc"),
&plugins.join("outside-kibana.jsonc"),
);
symlink_file(
&targets.join("missing.jsonc"),
&plugins.join("broken-kibana.jsonc"),
);
let matcher = globset::Glob::new("**/*-kibana.jsonc")
.unwrap()
.compile_matcher();
let paths = discover_manifest_paths(root, &matcher);
let relative: Vec<String> = paths
.iter()
.filter_map(|path| root_relative_forward_slash(path, root))
.collect();
assert_eq!(relative, vec!["plugins/inside-kibana.jsonc"]);
}
fn kinds(reports: &[RuleReport]) -> Vec<WarningKind> {
reports
.iter()
.flat_map(|r| r.warnings.iter().map(|w| w.kind))
.collect()
}
#[test]
fn check_reports_matched_manifests_when_gate_and_seeded_entries() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_manifest(
root,
"plugins/alpha/kibana.jsonc",
r#"{"type":"plugin","plugin":{"browser":true,"server":true}}"#,
);
write_manifest(
root,
"plugins/beta/kibana.jsonc",
r#"{"type":"plugin","plugin":{"browser":true,"server":false}}"#,
);
let ext = plugin_with(vec![rule(
"**/kibana.jsonc",
&[("type", Value::String("plugin".into()))],
vec![
seed(
"public/index.{ts,tsx}",
&[("plugin.browser", Value::Bool(true))],
),
seed(
"server/index.{ts,tsx}",
&[("plugin.server", Value::Bool(true))],
),
],
)]);
let reports = check_manifest_entries(&ext, root);
assert_eq!(reports.len(), 1);
let report = &reports[0];
assert!(
report.warnings.is_empty(),
"clean plugin, got {:?}",
report.warnings
);
assert_eq!(
report.manifests_matched,
vec![
"plugins/alpha/kibana.jsonc".to_string(),
"plugins/beta/kibana.jsonc".to_string()
]
);
let beta = report
.matched
.iter()
.find(|m| m.path == "plugins/beta/kibana.jsonc")
.expect("beta matched");
assert!(beta.when_passed);
assert!(beta.seeded.iter().any(|s| s.contains("beta/public/index")));
assert!(
!beta.seeded.iter().any(|s| s.contains("server/index")),
"beta server:false must not seed the server entry, got {:?}",
beta.seeded
);
}
#[test]
fn check_warns_manifests_matched_none() {
let dir = tempfile::tempdir().unwrap();
let ext = plugin_with(vec![rule(
"**/nonexistent.jsonc",
&[],
vec![seed("public/index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, dir.path());
assert!(kinds(&reports).contains(&WarningKind::ManifestsMatchedNone));
assert_eq!(
reports[0].warnings[0].glob.as_deref(),
Some("**/nonexistent.jsonc")
);
}
#[test]
fn check_warns_field_path_unresolved_on_typo() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_manifest(
root,
"plugins/alpha/kibana.jsonc",
r#"{"type":"plugin","plugin":{"browser":true}}"#,
);
let ext = plugin_with(vec![rule(
"**/kibana.jsonc",
&[("type", Value::String("plugin".into()))],
vec![seed("${plugin.extarPublicDirs}/index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, root);
let warn = reports[0]
.warnings
.iter()
.find(|w| w.kind == WarningKind::FieldPathUnresolved)
.expect("field-path-unresolved warning");
assert_eq!(warn.field_path.as_deref(), Some("plugin.extarPublicDirs"));
}
#[test]
fn check_reports_interpolation_limits_without_partial_seeding() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let values = (0..=MAX_MANIFEST_FIELD_VALUES)
.map(|index| index.to_string())
.collect::<Vec<_>>();
write_manifest(
root,
"plugins/alpha/manifest.json",
&serde_json::json!({ "entries": values }).to_string(),
);
let ext = plugin_with(vec![rule(
"**/manifest.json",
&[],
vec![seed("static.ts", &[]), seed("${entries}/index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, root);
let warning = reports[0]
.warnings
.iter()
.find(|warning| warning.kind == WarningKind::FieldValuesLimitExceeded)
.expect("field-values-limit-exceeded warning");
assert_eq!(warning.field_path.as_deref(), Some("entries"));
assert_eq!(
warning.manifest.as_deref(),
Some("plugins/alpha/manifest.json")
);
assert_eq!(
warning.kind.expansion_limit(),
Some(MAX_MANIFEST_FIELD_VALUES)
);
assert_eq!(
reports[0].matched[0].seeded,
vec!["plugins/alpha/static.ts"],
"a limited template must not suppress valid sibling seeds"
);
}
#[test]
fn check_reports_wildcard_gate_limits_without_a_false_exclusion_warning() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let items = std::iter::repeat_with(|| serde_json::json!({ "enabled": true }))
.take(MAX_MANIFEST_FIELD_VALUES + 1)
.collect::<Vec<_>>();
write_manifest(
root,
"plugins/alpha/manifest.json",
&serde_json::json!({ "items": items }).to_string(),
);
let ext = plugin_with(vec![rule(
"**/manifest.json",
&[("items[*].enabled", Value::Bool(true))],
vec![seed("index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, root);
assert!(
kinds(&reports).contains(&WarningKind::FieldValuesLimitExceeded),
"wildcard fan-out must report its explicit bound"
);
assert!(
!kinds(&reports).contains(&WarningKind::WhenExcludedAll),
"an evaluation limit is not a false 'when' result"
);
assert!(!reports[0].matched[0].when_passed);
}
#[test]
fn check_warns_when_excluded_all() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"package"}"#);
let ext = plugin_with(vec![rule(
"**/kibana.jsonc",
&[("type", Value::String("plugin".into()))],
vec![seed("public/index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, root);
assert!(kinds(&reports).contains(&WarningKind::WhenExcludedAll));
}
#[test]
fn check_warns_manifest_parse_failed_per_file() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_manifest(root, "plugins/good/kibana.jsonc", r#"{"type":"plugin"}"#);
write_manifest(root, "plugins/bad/kibana.jsonc", "{ this is not valid json");
let ext = plugin_with(vec![rule(
"**/kibana.jsonc",
&[("type", Value::String("plugin".into()))],
vec![seed("public/index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, root);
let warn = reports[0]
.warnings
.iter()
.find(|w| w.kind == WarningKind::ManifestParseFailed)
.expect("manifest-parse-failed warning");
assert_eq!(warn.manifest.as_deref(), Some("plugins/bad/kibana.jsonc"));
}
#[test]
fn check_output_is_deterministic_across_walk_order() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for name in ["mmm", "aaa", "zzz", "ccc"] {
write_manifest(
root,
&format!("plugins/{name}/kibana.jsonc"),
r#"{"type":"plugin"}"#,
);
}
let ext = plugin_with(vec![rule(
"**/kibana.jsonc",
&[("type", Value::String("plugin".into()))],
vec![seed("../../../../escape/index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, root);
let r = &reports[0];
let mut sorted = r.manifests_matched.clone();
sorted.sort();
assert_eq!(
r.manifests_matched, sorted,
"manifests_matched must be sorted"
);
let warn_manifests: Vec<&str> = r
.warnings
.iter()
.filter_map(|w| w.manifest.as_deref())
.collect();
let mut sorted_w = warn_manifests.clone();
sorted_w.sort_unstable();
assert_eq!(
warn_manifests, sorted_w,
"entry-outside-root warnings must be sorted"
);
}
#[test]
fn check_warns_entry_outside_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"plugin"}"#);
let ext = plugin_with(vec![rule(
"**/kibana.jsonc",
&[("type", Value::String("plugin".into()))],
vec![seed("../../../../escape/index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, root);
let warn = reports[0]
.warnings
.iter()
.find(|w| w.kind == WarningKind::EntryOutsideRoot)
.expect("entry-outside-root warning");
assert!(warn.entry.as_deref().is_some_and(|e| e.contains("escape")));
assert_eq!(warn.manifest.as_deref(), Some("plugins/alpha/kibana.jsonc"));
}
}