use crate::json::{parse as parse_json, Json, ParseError};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ContentScript {
pub matches: Vec<String>,
pub exclude_matches: Vec<String>,
pub js: Vec<String>,
pub css: Vec<String>,
pub run_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Manifest {
pub manifest_version: Option<i64>,
pub name: Option<String>,
pub version: Option<String>,
pub description: Option<String>,
pub permissions: Vec<String>,
pub optional_permissions: Vec<String>,
pub host_permissions: Vec<String>,
pub content_scripts: Vec<ContentScript>,
}
impl Manifest {
pub fn from_json(input: &str) -> Result<Self, ParseError> {
let value = parse_json(input)?;
Self::from_json_value(&value)
}
pub fn from_json_value(value: &Json) -> Result<Self, ParseError> {
if !matches!(value, Json::Object(_)) {
return Err(ParseError {
offset: 0,
message: "manifest root is not a JSON object".to_string(),
});
}
let manifest_version = value.get("manifest_version").and_then(|v| v.as_i64());
let name = value.get("name").and_then(|v| v.as_str()).map(str::to_string);
let version = value.get("version").and_then(|v| v.as_str()).map(str::to_string);
let description = value
.get("description")
.and_then(|v| v.as_str())
.map(str::to_string);
let permissions = string_array(value, "permissions")?;
let optional_permissions = string_array(value, "optional_permissions")?;
let host_permissions = string_array(value, "host_permissions")?;
let content_scripts = match value.get("content_scripts") {
None => Vec::new(),
Some(Json::Null) => Vec::new(),
Some(Json::Array(items)) => {
let mut out = Vec::with_capacity(items.len());
for item in items {
out.push(ContentScript::from_json_value(item)?);
}
out
}
Some(other) => {
return Err(ParseError {
offset: 0,
message: format!(
"content_scripts must be an array, found {}",
json_type_name(other)
),
})
}
};
Ok(Manifest {
manifest_version,
name,
version,
description,
permissions,
optional_permissions,
host_permissions,
content_scripts,
})
}
pub fn all_grants(&self) -> Vec<String> {
let mut out =
Vec::with_capacity(self.permissions.len() + self.host_permissions.len());
out.extend(self.permissions.iter().cloned());
out.extend(self.host_permissions.iter().cloned());
out
}
pub fn content_match_patterns(&self) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for cs in &self.content_scripts {
for m in &cs.matches {
if !seen.iter().any(|s| s == m) {
seen.push(m.clone());
}
}
}
seen
}
pub fn is_mv3(&self) -> bool {
self.manifest_version == Some(3)
}
pub fn is_mv2(&self) -> bool {
self.manifest_version == Some(2)
}
}
impl ContentScript {
fn from_json_value(value: &Json) -> Result<Self, ParseError> {
if !matches!(value, Json::Object(_)) {
return Err(ParseError {
offset: 0,
message: "content_scripts entry is not an object".to_string(),
});
}
Ok(ContentScript {
matches: string_array(value, "matches")?,
exclude_matches: string_array(value, "exclude_matches")?,
js: string_array(value, "js")?,
css: string_array(value, "css")?,
run_at: value.get("run_at").and_then(|v| v.as_str()).map(str::to_string),
})
}
}
fn string_array(obj: &Json, key: &str) -> Result<Vec<String>, ParseError> {
match obj.get(key) {
None => Ok(Vec::new()),
Some(Json::Null) => Ok(Vec::new()),
Some(Json::Array(items)) => {
let mut out = Vec::with_capacity(items.len());
for item in items {
match item {
Json::String(s) => out.push(s.clone()),
other => {
return Err(ParseError {
offset: 0,
message: format!(
"{} array contains a non-string element ({})",
key,
json_type_name(other)
),
})
}
}
}
Ok(out)
}
Some(other) => Err(ParseError {
offset: 0,
message: format!(
"{} must be an array of strings, found {}",
key,
json_type_name(other)
),
}),
}
}
fn json_type_name(value: &Json) -> &'static str {
match value {
Json::Null => "null",
Json::Bool(_) => "bool",
Json::Number(_) => "number",
Json::String(_) => "string",
Json::Array(_) => "array",
Json::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
const FULL_MV3: &str = r#"{
"manifest_version": 3,
"name": "Privacy Badge",
"version": "1.4.2",
"description": "Shows a badge for trackers.",
"permissions": ["activeTab", "storage", "tabs"],
"optional_permissions": ["history"],
"host_permissions": ["https://*/*"],
"content_scripts": [
{
"matches": ["https://*/*"],
"exclude_matches": ["https://*.bank.com/*"],
"js": ["content.js"],
"css": ["style.css"],
"run_at": "document_start"
}
]
}"#;
#[test]
fn parses_full_mv3_manifest() {
let m = Manifest::from_json(FULL_MV3).expect("valid");
assert_eq!(m.manifest_version, Some(3));
assert_eq!(m.name.as_deref(), Some("Privacy Badge"));
assert_eq!(m.version.as_deref(), Some("1.4.2"));
assert_eq!(
m.description.as_deref(),
Some("Shows a badge for trackers.")
);
assert_eq!(m.permissions, ["activeTab", "storage", "tabs"]);
assert_eq!(m.optional_permissions, ["history"]);
assert_eq!(m.host_permissions, ["https://*/*"]);
assert!(m.is_mv3());
assert!(!m.is_mv2());
}
#[test]
fn parses_content_scripts_block() {
let m = Manifest::from_json(FULL_MV3).expect("valid");
assert_eq!(m.content_scripts.len(), 1);
let cs = &m.content_scripts[0];
assert_eq!(cs.matches, ["https://*/*"]);
assert_eq!(cs.exclude_matches, ["https://*.bank.com/*"]);
assert_eq!(cs.js, ["content.js"]);
assert_eq!(cs.css, ["style.css"]);
assert_eq!(cs.run_at.as_deref(), Some("document_start"));
}
#[test]
fn mv2_detected() {
let m = Manifest::from_json(r#"{ "manifest_version": 2, "name": "Old" }"#).unwrap();
assert!(m.is_mv2());
assert!(!m.is_mv3());
}
#[test]
fn missing_fields_default_to_none() {
let m = Manifest::from_json(r#"{}"#).expect("empty object is valid");
assert_eq!(m.manifest_version, None);
assert_eq!(m.name, None);
assert_eq!(m.version, None);
assert!(m.permissions.is_empty());
assert!(m.host_permissions.is_empty());
assert!(m.content_scripts.is_empty());
assert!(!m.is_mv3());
}
#[test]
fn null_permissions_treated_as_empty() {
let m = Manifest::from_json(r#"{ "permissions": null }"#).expect("null ok");
assert!(m.permissions.is_empty());
}
#[test]
fn empty_arrays_round_trip() {
let m = Manifest::from_json(r#"{ "permissions": [], "host_permissions": [] }"#).unwrap();
assert!(m.permissions.is_empty());
assert!(m.host_permissions.is_empty());
}
#[test]
fn non_object_root_rejected() {
let err = Manifest::from_json(r#"["not", "an", "object"]"#).unwrap_err();
assert!(err.message.contains("not a JSON object"), "{}", err.message);
}
#[test]
fn permissions_wrong_type_rejected() {
let err = Manifest::from_json(r#"{ "permissions": "activeTab" }"#).unwrap_err();
assert!(err.message.contains("permissions"), "{}", err.message);
assert!(err.message.contains("array"), "{}", err.message);
}
#[test]
fn non_string_element_rejected() {
let err = Manifest::from_json(r#"{ "permissions": ["activeTab", 42] }"#).unwrap_err();
assert!(err.message.contains("non-string"), "{}", err.message);
}
#[test]
fn content_scripts_wrong_type_rejected() {
let err = Manifest::from_json(r#"{ "content_scripts": "nope" }"#).unwrap_err();
assert!(err.message.contains("content_scripts"), "{}", err.message);
}
#[test]
fn content_script_entry_wrong_type_rejected() {
let err =
Manifest::from_json(r#"{ "content_scripts": ["not-an-object"] }"#).unwrap_err();
assert!(err.message.contains("not an object"), "{}", err.message);
}
#[test]
fn all_grants_concatenates_permissions_and_hosts() {
let m = Manifest::from_json(FULL_MV3).unwrap();
assert_eq!(
m.all_grants(),
["activeTab", "storage", "tabs", "https://*/*"]
);
}
#[test]
fn content_match_patterns_dedupes_across_scripts() {
let m = Manifest::from_json(
r#"{ "content_scripts": [
{ "matches": ["https://*/*"], "js": ["a.js"] },
{ "matches": ["https://*.example.com/*", "https://*/*"] }
] }"#,
)
.unwrap();
assert_eq!(
m.content_match_patterns(),
["https://*/*", "https://*.example.com/*"]
);
}
#[test]
fn handles_string_escapes_and_unicode() {
let m = Manifest::from_json(
r#"{ "name": "A \"cool\" ✓ ext 😀" }"#,
)
.unwrap();
let name = m.name.as_deref().unwrap();
assert!(name.contains("\"cool\""));
assert!(name.contains('\u{2713}'));
assert!(name.contains('\u{1F600}')); }
#[test]
fn trailing_garbage_rejected() {
let err = Manifest::from_json(r#"{ "name": "X" } junk"#).unwrap_err();
assert!(err.message.contains("trailing"), "{}", err.message);
}
#[test]
fn manifest_version_as_float_is_not_silently_coerced() {
let m = Manifest::from_json(r#"{ "manifest_version": 3.0 }"#).unwrap();
assert_eq!(m.manifest_version, None);
assert!(!m.is_mv3());
}
}