1use crate::json::{parse as parse_json, Json, ParseError};
5
6#[derive(Debug, Clone, PartialEq, Eq, Default)]
13pub struct ContentScript {
14 pub matches: Vec<String>,
17 pub exclude_matches: Vec<String>,
19 pub js: Vec<String>,
21 pub css: Vec<String>,
23 pub run_at: Option<String>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct Manifest {
40 pub manifest_version: Option<i64>,
42 pub name: Option<String>,
44 pub version: Option<String>,
46 pub description: Option<String>,
48 pub permissions: Vec<String>,
50 pub optional_permissions: Vec<String>,
52 pub host_permissions: Vec<String>,
54 pub content_scripts: Vec<ContentScript>,
56}
57
58impl Manifest {
59 pub fn from_json(input: &str) -> Result<Self, ParseError> {
72 let value = parse_json(input)?;
73 Self::from_json_value(&value)
74 }
75
76 pub fn from_json_value(value: &Json) -> Result<Self, ParseError> {
79 if !matches!(value, Json::Object(_)) {
81 return Err(ParseError {
82 offset: 0,
83 message: "manifest root is not a JSON object".to_string(),
84 });
85 }
86
87 let manifest_version = value.get("manifest_version").and_then(|v| v.as_i64());
88 let name = value.get("name").and_then(|v| v.as_str()).map(str::to_string);
89 let version = value.get("version").and_then(|v| v.as_str()).map(str::to_string);
90 let description = value
91 .get("description")
92 .and_then(|v| v.as_str())
93 .map(str::to_string);
94
95 let permissions = string_array(value, "permissions")?;
96 let optional_permissions = string_array(value, "optional_permissions")?;
97 let host_permissions = string_array(value, "host_permissions")?;
98
99 let content_scripts = match value.get("content_scripts") {
100 None => Vec::new(),
101 Some(Json::Null) => Vec::new(),
102 Some(Json::Array(items)) => {
103 let mut out = Vec::with_capacity(items.len());
104 for item in items {
105 out.push(ContentScript::from_json_value(item)?);
106 }
107 out
108 }
109 Some(other) => {
110 return Err(ParseError {
111 offset: 0,
112 message: format!(
113 "content_scripts must be an array, found {}",
114 json_type_name(other)
115 ),
116 })
117 }
118 };
119
120 Ok(Manifest {
121 manifest_version,
122 name,
123 version,
124 description,
125 permissions,
126 optional_permissions,
127 host_permissions,
128 content_scripts,
129 })
130 }
131
132 pub fn all_grants(&self) -> Vec<String> {
142 let mut out =
143 Vec::with_capacity(self.permissions.len() + self.host_permissions.len());
144 out.extend(self.permissions.iter().cloned());
145 out.extend(self.host_permissions.iter().cloned());
146 out
147 }
148
149 pub fn content_match_patterns(&self) -> Vec<String> {
162 let mut seen: Vec<String> = Vec::new();
163 for cs in &self.content_scripts {
164 for m in &cs.matches {
165 if !seen.iter().any(|s| s == m) {
166 seen.push(m.clone());
167 }
168 }
169 }
170 seen
171 }
172
173 pub fn is_mv3(&self) -> bool {
176 self.manifest_version == Some(3)
177 }
178
179 pub fn is_mv2(&self) -> bool {
182 self.manifest_version == Some(2)
183 }
184}
185
186impl ContentScript {
187 fn from_json_value(value: &Json) -> Result<Self, ParseError> {
188 if !matches!(value, Json::Object(_)) {
189 return Err(ParseError {
190 offset: 0,
191 message: "content_scripts entry is not an object".to_string(),
192 });
193 }
194 Ok(ContentScript {
195 matches: string_array(value, "matches")?,
196 exclude_matches: string_array(value, "exclude_matches")?,
197 js: string_array(value, "js")?,
198 css: string_array(value, "css")?,
199 run_at: value.get("run_at").and_then(|v| v.as_str()).map(str::to_string),
200 })
201 }
202}
203
204fn string_array(obj: &Json, key: &str) -> Result<Vec<String>, ParseError> {
208 match obj.get(key) {
209 None => Ok(Vec::new()),
210 Some(Json::Null) => Ok(Vec::new()),
211 Some(Json::Array(items)) => {
212 let mut out = Vec::with_capacity(items.len());
213 for item in items {
214 match item {
215 Json::String(s) => out.push(s.clone()),
216 other => {
217 return Err(ParseError {
218 offset: 0,
219 message: format!(
220 "{} array contains a non-string element ({})",
221 key,
222 json_type_name(other)
223 ),
224 })
225 }
226 }
227 }
228 Ok(out)
229 }
230 Some(other) => Err(ParseError {
231 offset: 0,
232 message: format!(
233 "{} must be an array of strings, found {}",
234 key,
235 json_type_name(other)
236 ),
237 }),
238 }
239}
240
241fn json_type_name(value: &Json) -> &'static str {
242 match value {
243 Json::Null => "null",
244 Json::Bool(_) => "bool",
245 Json::Number(_) => "number",
246 Json::String(_) => "string",
247 Json::Array(_) => "array",
248 Json::Object(_) => "object",
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 const FULL_MV3: &str = r#"{
257 "manifest_version": 3,
258 "name": "Privacy Badge",
259 "version": "1.4.2",
260 "description": "Shows a badge for trackers.",
261 "permissions": ["activeTab", "storage", "tabs"],
262 "optional_permissions": ["history"],
263 "host_permissions": ["https://*/*"],
264 "content_scripts": [
265 {
266 "matches": ["https://*/*"],
267 "exclude_matches": ["https://*.bank.com/*"],
268 "js": ["content.js"],
269 "css": ["style.css"],
270 "run_at": "document_start"
271 }
272 ]
273 }"#;
274
275 #[test]
276 fn parses_full_mv3_manifest() {
277 let m = Manifest::from_json(FULL_MV3).expect("valid");
278 assert_eq!(m.manifest_version, Some(3));
279 assert_eq!(m.name.as_deref(), Some("Privacy Badge"));
280 assert_eq!(m.version.as_deref(), Some("1.4.2"));
281 assert_eq!(
282 m.description.as_deref(),
283 Some("Shows a badge for trackers.")
284 );
285 assert_eq!(m.permissions, ["activeTab", "storage", "tabs"]);
286 assert_eq!(m.optional_permissions, ["history"]);
287 assert_eq!(m.host_permissions, ["https://*/*"]);
288 assert!(m.is_mv3());
289 assert!(!m.is_mv2());
290 }
291
292 #[test]
293 fn parses_content_scripts_block() {
294 let m = Manifest::from_json(FULL_MV3).expect("valid");
295 assert_eq!(m.content_scripts.len(), 1);
296 let cs = &m.content_scripts[0];
297 assert_eq!(cs.matches, ["https://*/*"]);
298 assert_eq!(cs.exclude_matches, ["https://*.bank.com/*"]);
299 assert_eq!(cs.js, ["content.js"]);
300 assert_eq!(cs.css, ["style.css"]);
301 assert_eq!(cs.run_at.as_deref(), Some("document_start"));
302 }
303
304 #[test]
305 fn mv2_detected() {
306 let m = Manifest::from_json(r#"{ "manifest_version": 2, "name": "Old" }"#).unwrap();
307 assert!(m.is_mv2());
308 assert!(!m.is_mv3());
309 }
310
311 #[test]
312 fn missing_fields_default_to_none() {
313 let m = Manifest::from_json(r#"{}"#).expect("empty object is valid");
314 assert_eq!(m.manifest_version, None);
315 assert_eq!(m.name, None);
316 assert_eq!(m.version, None);
317 assert!(m.permissions.is_empty());
318 assert!(m.host_permissions.is_empty());
319 assert!(m.content_scripts.is_empty());
320 assert!(!m.is_mv3());
321 }
322
323 #[test]
324 fn null_permissions_treated_as_empty() {
325 let m = Manifest::from_json(r#"{ "permissions": null }"#).expect("null ok");
326 assert!(m.permissions.is_empty());
327 }
328
329 #[test]
330 fn empty_arrays_round_trip() {
331 let m = Manifest::from_json(r#"{ "permissions": [], "host_permissions": [] }"#).unwrap();
332 assert!(m.permissions.is_empty());
333 assert!(m.host_permissions.is_empty());
334 }
335
336 #[test]
337 fn non_object_root_rejected() {
338 let err = Manifest::from_json(r#"["not", "an", "object"]"#).unwrap_err();
339 assert!(err.message.contains("not a JSON object"), "{}", err.message);
340 }
341
342 #[test]
343 fn permissions_wrong_type_rejected() {
344 let err = Manifest::from_json(r#"{ "permissions": "activeTab" }"#).unwrap_err();
345 assert!(err.message.contains("permissions"), "{}", err.message);
346 assert!(err.message.contains("array"), "{}", err.message);
347 }
348
349 #[test]
350 fn non_string_element_rejected() {
351 let err = Manifest::from_json(r#"{ "permissions": ["activeTab", 42] }"#).unwrap_err();
352 assert!(err.message.contains("non-string"), "{}", err.message);
353 }
354
355 #[test]
356 fn content_scripts_wrong_type_rejected() {
357 let err = Manifest::from_json(r#"{ "content_scripts": "nope" }"#).unwrap_err();
358 assert!(err.message.contains("content_scripts"), "{}", err.message);
359 }
360
361 #[test]
362 fn content_script_entry_wrong_type_rejected() {
363 let err =
364 Manifest::from_json(r#"{ "content_scripts": ["not-an-object"] }"#).unwrap_err();
365 assert!(err.message.contains("not an object"), "{}", err.message);
366 }
367
368 #[test]
369 fn all_grants_concatenates_permissions_and_hosts() {
370 let m = Manifest::from_json(FULL_MV3).unwrap();
371 assert_eq!(
372 m.all_grants(),
373 ["activeTab", "storage", "tabs", "https://*/*"]
374 );
375 }
376
377 #[test]
378 fn content_match_patterns_dedupes_across_scripts() {
379 let m = Manifest::from_json(
380 r#"{ "content_scripts": [
381 { "matches": ["https://*/*"], "js": ["a.js"] },
382 { "matches": ["https://*.example.com/*", "https://*/*"] }
383 ] }"#,
384 )
385 .unwrap();
386 assert_eq!(
387 m.content_match_patterns(),
388 ["https://*/*", "https://*.example.com/*"]
389 );
390 }
391
392 #[test]
393 fn handles_string_escapes_and_unicode() {
394 let m = Manifest::from_json(
396 r#"{ "name": "A \"cool\" ✓ ext 😀" }"#,
397 )
398 .unwrap();
399 let name = m.name.as_deref().unwrap();
400 assert!(name.contains("\"cool\""));
401 assert!(name.contains('\u{2713}'));
402 assert!(name.contains('\u{1F600}')); }
404
405 #[test]
406 fn trailing_garbage_rejected() {
407 let err = Manifest::from_json(r#"{ "name": "X" } junk"#).unwrap_err();
408 assert!(err.message.contains("trailing"), "{}", err.message);
409 }
410
411 #[test]
412 fn manifest_version_as_float_is_not_silently_coerced() {
413 let m = Manifest::from_json(r#"{ "manifest_version": 3.0 }"#).unwrap();
417 assert_eq!(m.manifest_version, None);
418 assert!(!m.is_mv3());
419 }
420}