Skip to main content

crx_manifest_parser/
parse.rs

1//! Typed projection from a parsed JSON manifest into the fields an extension
2//! scanner cares about.
3
4use crate::json::{parse as parse_json, Json, ParseError};
5
6/// A single `content_scripts` entry from the manifest.
7///
8/// Content scripts are the dominant cross-origin exposure surface in a Chrome
9/// extension: each entry injects code into every page matching one of its
10/// `matches` patterns, so the match list plus the script file list is the
11/// first thing a security review looks at.
12#[derive(Debug, Clone, PartialEq, Eq, Default)]
13pub struct ContentScript {
14    /// The `matches` array — match patterns the script runs on. Required by
15    /// the manifest spec; if absent or non-string this is empty.
16    pub matches: Vec<String>,
17    /// The `exclude_matches` array, if present.
18    pub exclude_matches: Vec<String>,
19    /// The `js` files injected into matching pages.
20    pub js: Vec<String>,
21    /// The `css` files injected into matching pages.
22    pub css: Vec<String>,
23    /// Whether `run_at` is `document_start` (scripts inject before the page
24    /// builds its DOM, the position that sees every password field as it is
25    /// created). `None` means the field was absent (Chrome defaults to
26    /// `document_idle`).
27    pub run_at: Option<String>,
28}
29
30/// A typed view over the fields of a Chrome `manifest.json` that an extension
31/// privacy &amp; security scanner inspects.
32///
33/// Every field is optional: manifests are user-supplied and routinely omit
34/// keys, so the parser never hard-fails on a missing field — it surfaces
35/// `None` / an empty `Vec` instead. A hard failure only happens when the
36/// document is not valid JSON or a field has the wrong JSON type (e.g.
37/// `permissions` given as a string instead of an array).
38#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct Manifest {
40    /// `manifest_version`. MV3 is `3`; legacy MV2 is `2`.
41    pub manifest_version: Option<i64>,
42    /// `name`.
43    pub name: Option<String>,
44    /// `version` (Chrome's own dotted-decimal string).
45    pub version: Option<String>,
46    /// `description`.
47    pub description: Option<String>,
48    /// `permissions` — named API tokens (`activeTab`, `tabs`, ...).
49    pub permissions: Vec<String>,
50    /// `optional_permissions` — tokens requested at runtime.
51    pub optional_permissions: Vec<String>,
52    /// `host_permissions` — match-patterns granting site access.
53    pub host_permissions: Vec<String>,
54    /// `content_scripts` entries.
55    pub content_scripts: Vec<ContentScript>,
56}
57
58impl Manifest {
59    /// Parse a `manifest.json` document into a typed [`Manifest`].
60    ///
61    /// Returns an error only when the document is not valid JSON or a known
62    /// field has the wrong type; missing keys are tolerated and surface as
63    /// `None` / empty vectors.
64    ///
65    /// ```
66    /// use crx_manifest_parser::Manifest;
67    /// let m = Manifest::from_json(r#"{ "manifest_version": 3, "name": "X" }"#).unwrap();
68    /// assert_eq!(m.manifest_version, Some(3));
69    /// assert_eq!(m.name.as_deref(), Some("X"));
70    /// ```
71    pub fn from_json(input: &str) -> Result<Self, ParseError> {
72        let value = parse_json(input)?;
73        Self::from_json_value(&value)
74    }
75
76    /// Build a [`Manifest`] from an already-parsed [`Json`] value. Useful when
77    /// a caller wants to inspect raw fields via the JSON model as well.
78    pub fn from_json_value(value: &Json) -> Result<Self, ParseError> {
79        // The top level must be an object for a manifest.
80        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    /// Concatenation of `permissions` and `host_permissions` — the full set
133    /// of grant tokens an extension requests, in declaration order. This is
134    /// the input a permission auditor (e.g. `permission-auditor`) consumes.
135    ///
136    /// ```
137    /// use crx_manifest_parser::Manifest;
138    /// let m = Manifest::from_json(r#"{ "permissions": ["tabs"], "host_permissions": ["<all_urls>"] }"#).unwrap();
139    /// assert_eq!(m.all_grants(), &["tabs".to_string(), "<all_urls>".to_string()]);
140    /// ```
141    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    /// Convenience: every `matches` pattern across all content scripts,
150    /// deduplicated, in first-seen order. This is the set of sites the
151    /// extension can run injected code on.
152    ///
153    /// ```
154    /// use crx_manifest_parser::Manifest;
155    /// let m = Manifest::from_json(r#"{ "content_scripts": [
156    ///     { "matches": ["https://*/*"], "js": ["a.js"] },
157    ///     { "matches": ["https://*.example.com/*", "https://*/*"] }
158    /// ] }"#).unwrap();
159    /// assert_eq!(m.content_match_patterns(), &["https://*/*".to_string(), "https://*.example.com/*".to_string()]);
160    /// ```
161    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    /// Whether the manifest declares Manifest V3 (`manifest_version == 3`).
174    /// Returns `false` for MV2, missing, or any other value.
175    pub fn is_mv3(&self) -> bool {
176        self.manifest_version == Some(3)
177    }
178
179    /// Whether the manifest declares the legacy Manifest V2
180    /// (`manifest_version == 2`).
181    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
204/// Extract a string-array field from an object, tolerating `null` and
205/// missing keys (both yield an empty `Vec`), but erroring if the field is
206/// present with a non-array type or contains non-string elements.
207fn 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        // Escaped quotes, a backslash, and a surrogate-pair emoji in the name.
395        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}')); // 😀
403    }
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        // Chrome writes manifest_version as an integer. A fractional value is
414        // surfaced as None rather than silently truncated — callers should
415        // not have to guess whether Some(3) came from 3 or 3.9.
416        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}