Skip to main content

jsdet_chrome_ext/
bridge.rs

1//! Chrome Extension Bridge — implements the Bridge trait for chrome.* APIs.
2//!
3//! Every chrome.* call is routed through this bridge. The bridge:
4//! 1. Logs the call as an Observation
5//! 2. Checks if the call involves tainted data (via profile)
6//! 3. Returns simulated data from ExtensionState
7//! 4. Enforces CSP if configured
8
9use std::sync::{Arc, Mutex};
10
11use jsdet_core::bridge::Bridge;
12use jsdet_core::observation::{Observation, Value};
13
14use crate::manifest::Manifest;
15use crate::profile::AnalysisProfile;
16use crate::state::ExtensionState;
17
18/// The Chrome Extension API bridge.
19pub struct ChromeExtBridge {
20    /// Extension manifest (permissions, CSP, etc.).
21    manifest: Manifest,
22    /// Analysis profile (sources, sinks, transforms, sanitizers).
23    profile: AnalysisProfile,
24    /// Simulated browser state.
25    state: ExtensionState,
26    /// Collected observations.
27    observations: Arc<Mutex<Vec<Observation>>>,
28}
29
30impl ChromeExtBridge {
31    /// Create a new bridge with the given manifest, profile, and state.
32    pub fn new(manifest: Manifest, profile: AnalysisProfile, state: ExtensionState) -> Self {
33        Self {
34            manifest,
35            profile,
36            state,
37            observations: Arc::new(Mutex::new(Vec::new())),
38        }
39    }
40
41    /// Drain collected observations.
42    /// CRITICAL FIX: Handle poisoned mutex - recover data even if another thread panicked.
43    pub fn take_observations(&self) -> Vec<Observation> {
44        let mut guard = self
45            .observations
46            .lock()
47            .unwrap_or_else(std::sync::PoisonError::into_inner);
48        std::mem::take(&mut *guard)
49    }
50
51    fn observe(&self, obs: Observation) {
52        // CRITICAL FIX: Handle poisoned mutex gracefully - log observation if possible
53        if let Ok(mut guard) = self.observations.lock() {
54            guard.push(obs);
55        }
56    }
57
58    fn handle_tabs(&self, method: &str, args: &[Value]) -> Result<Value, String> {
59        match method {
60            "query" => {
61                // CRITICAL FIX: Handle poisoned mutex - return empty data on error
62                let guard = self
63                    .state
64                    .tabs
65                    .lock()
66                    .unwrap_or_else(std::sync::PoisonError::into_inner);
67                let json = serde_json::to_string(&*guard).unwrap_or_default();
68                Ok(Value::json(json))
69            }
70            "create" => {
71                self.observe(Observation::ApiCall {
72                    api: "chrome.tabs.create".to_string(),
73                    args: args.to_vec(),
74                    result: Value::json(r#"{"id": 999}"#),
75                });
76                Ok(Value::json(r#"{"id": 999}"#))
77            }
78            "update" => {
79                self.observe(Observation::ApiCall {
80                    api: "chrome.tabs.update".into(),
81                    args: args.to_vec(),
82                    result: Value::Null,
83                });
84                // Check if URL argument is tainted → potential open redirect.
85                if let Some(sink) = self.profile.is_sink("chrome.tabs.update") {
86                    self.observe(Observation::ApiCall {
87                        api: format!("SINK:chrome.tabs.update [{}]", sink.cwe),
88                        args: args.to_vec(),
89                        result: Value::Null,
90                    });
91                }
92                Ok(Value::Null)
93            }
94            "executeScript" | "sendMessage" => {
95                self.observe(Observation::ApiCall {
96                    api: format!("chrome.tabs.{method}"),
97                    args: args.to_vec(),
98                    result: Value::Null,
99                });
100                if let Some(sink) = self.profile.is_sink(&format!("chrome.tabs.{method}")) {
101                    self.observe(Observation::ApiCall {
102                        api: format!("SINK:chrome.tabs.{method} [{}]", sink.cwe),
103                        args: args.to_vec(),
104                        result: Value::Null,
105                    });
106                }
107                Ok(Value::Null)
108            }
109            _ => Err(format!("chrome.tabs.{method} is not defined")),
110        }
111    }
112
113    fn handle_cookies(&self, method: &str, args: &[Value]) -> Result<Value, String> {
114        match method {
115            "getAll" | "get" => {
116                // CRITICAL FIX: Handle poisoned mutex - recover data even if poisoned
117                let guard = self
118                    .state
119                    .cookies
120                    .lock()
121                    .unwrap_or_else(std::sync::PoisonError::into_inner);
122                let json = serde_json::to_string(&*guard).unwrap_or_default();
123                self.observe(Observation::ApiCall {
124                    api: format!("chrome.cookies.{method}"),
125                    args: args.to_vec(),
126                    result: Value::json(json.clone()),
127                });
128                Ok(Value::json(json))
129            }
130            "set" | "remove" => {
131                self.observe(Observation::ApiCall {
132                    api: format!("chrome.cookies.{method}"),
133                    args: args.to_vec(),
134                    result: Value::Null,
135                });
136                Ok(Value::Null)
137            }
138            _ => Err(format!("chrome.cookies.{method} is not defined")),
139        }
140    }
141
142    fn handle_storage(&self, method: &str, args: &[Value]) -> Result<Value, String> {
143        // Split "local.get" → ("local", "get") or just "get" → ("local", "get")
144        let (area, op) = if method.contains('.') {
145            let parts: Vec<&str> = method.splitn(2, '.').collect();
146            (parts[0], parts[1])
147        } else {
148            ("local", method)
149        };
150
151        let storage = match area {
152            "sync" => &self.state.storage_sync,
153            _ => &self.state.storage_local,
154        };
155
156        match op {
157            "get" => {
158                let data = storage.lock().unwrap();
159                let json = serde_json::to_string(&*data).unwrap_or_default();
160                self.observe(Observation::ApiCall {
161                    api: format!("chrome.storage.{area}.get"),
162                    args: args.to_vec(),
163                    result: Value::json(json.clone()),
164                });
165                Ok(Value::json(json))
166            }
167            "set" => {
168                self.observe(Observation::ApiCall {
169                    api: format!("chrome.storage.{area}.set"),
170                    args: args.to_vec(),
171                    result: Value::Null,
172                });
173                // Actually store the value if we can parse it.
174                // CRITICAL FIX: Handle poisoned mutex gracefully
175                if let Some(Value::Json(json, _)) = args.first()
176                    && let Ok(map) =
177                        serde_json::from_str::<std::collections::HashMap<String, String>>(json)
178                    && let Ok(mut guard) = storage.lock()
179                {
180                    guard.extend(map);
181                }
182                Ok(Value::Null)
183            }
184            "remove" | "clear" => {
185                self.observe(Observation::ApiCall {
186                    api: format!("chrome.storage.{area}.{op}"),
187                    args: args.to_vec(),
188                    result: Value::Null,
189                });
190                if op == "clear" {
191                    // CRITICAL FIX: Handle poisoned mutex gracefully
192                    if let Ok(mut guard) = storage.lock() {
193                        guard.clear();
194                    }
195                }
196                Ok(Value::Null)
197            }
198            _ => Err(format!("chrome.storage.{area}.{op} is not defined")),
199        }
200    }
201
202    fn handle_runtime(&self, method: &str, args: &[Value]) -> Result<Value, String> {
203        match method {
204            "getURL" => {
205                let path = args.first().and_then(|v| v.as_str()).unwrap_or("");
206                let url = format!("chrome-extension://{}/{}", self.state.extension_id, path);
207                Ok(Value::string(url))
208            }
209            "sendMessage" => {
210                self.observe(Observation::ApiCall {
211                    api: "chrome.runtime.sendMessage".into(),
212                    args: args.to_vec(),
213                    result: Value::Null,
214                });
215                // Check if this is also a source (outgoing message to other context).
216                if self
217                    .profile
218                    .is_source("chrome.runtime.sendMessage")
219                    .is_some()
220                {
221                    self.observe(Observation::ApiCall {
222                        api: "SOURCE:chrome.runtime.sendMessage".into(),
223                        args: args.to_vec(),
224                        result: Value::Null,
225                    });
226                }
227                Ok(Value::Null)
228            }
229            "getManifest" => {
230                let json = serde_json::to_string(&self.manifest).unwrap_or_default();
231                Ok(Value::json(json))
232            }
233            "id" => Ok(Value::string(self.state.extension_id.clone())),
234            _ => Err(format!("chrome.runtime.{method} is not defined")),
235        }
236    }
237
238    fn handle_scripting(&self, method: &str, args: &[Value]) -> Result<Value, String> {
239        match method {
240            "executeScript" | "insertCSS" | "registerContentScripts" => {
241                self.observe(Observation::ApiCall {
242                    api: format!("chrome.scripting.{method}"),
243                    args: args.to_vec(),
244                    result: Value::Null,
245                });
246                if let Some(sink) = self.profile.is_sink(&format!("chrome.scripting.{method}")) {
247                    self.observe(Observation::ApiCall {
248                        api: format!("SINK:chrome.scripting.{method} [{}]", sink.cwe),
249                        args: args.to_vec(),
250                        result: Value::Null,
251                    });
252                }
253                Ok(Value::Null)
254            }
255            _ => Err(format!("chrome.scripting.{method} is not defined")),
256        }
257    }
258
259    fn handle_web_request(&self, method: &str, args: &[Value]) -> Result<Value, String> {
260        self.observe(Observation::ApiCall {
261            api: format!("chrome.webRequest.{method}"),
262            args: args.to_vec(),
263            result: Value::Null,
264        });
265        Ok(Value::Null)
266    }
267
268    fn handle_alarms(&self, method: &str, args: &[Value]) -> Result<Value, String> {
269        match method {
270            "create" => {
271                self.observe(Observation::ApiCall {
272                    api: "chrome.alarms.create".into(),
273                    args: args.to_vec(),
274                    result: Value::Null,
275                });
276                Ok(Value::Null)
277            }
278            "get" | "getAll" | "clear" | "clearAll" => {
279                // CRITICAL FIX: Handle poisoned mutex - recover data even if poisoned
280                let guard = self
281                    .state
282                    .alarms
283                    .lock()
284                    .unwrap_or_else(std::sync::PoisonError::into_inner);
285                let json = serde_json::to_string(&*guard).unwrap_or_default();
286                Ok(Value::json(json))
287            }
288            _ => Err(format!("chrome.alarms.{method} is not defined")),
289        }
290    }
291
292    fn handle_permissions(&self, method: &str, _args: &[Value]) -> Result<Value, String> {
293        match method {
294            "getAll" => {
295                let perms = serde_json::json!({
296                    "permissions": self.manifest.permissions,
297                    "origins": self.manifest.host_permissions,
298                });
299                Ok(Value::json(perms.to_string()))
300            }
301            "contains" => Ok(Value::Bool(true)), // Always granted in sandbox.
302            "request" => Ok(Value::Bool(true)),
303            _ => Err(format!("chrome.permissions.{method} is not defined")),
304        }
305    }
306}
307
308impl Bridge for ChromeExtBridge {
309    fn call(&self, api: &str, args: &[Value]) -> Result<Value, String> {
310        // Route to the appropriate handler based on API prefix.
311        if let Some(method) = api.strip_prefix("chrome.tabs.") {
312            return self.handle_tabs(method, args);
313        }
314        if let Some(method) = api.strip_prefix("chrome.cookies.") {
315            return self.handle_cookies(method, args);
316        }
317        if let Some(method) = api.strip_prefix("chrome.storage.") {
318            return self.handle_storage(method, args);
319        }
320        if let Some(method) = api.strip_prefix("chrome.runtime.") {
321            return self.handle_runtime(method, args);
322        }
323        if let Some(method) = api.strip_prefix("chrome.scripting.") {
324            return self.handle_scripting(method, args);
325        }
326        if let Some(method) = api.strip_prefix("chrome.webRequest.") {
327            return self.handle_web_request(method, args);
328        }
329        if let Some(method) = api.strip_prefix("chrome.alarms.") {
330            return self.handle_alarms(method, args);
331        }
332        if let Some(method) = api.strip_prefix("chrome.permissions.") {
333            return self.handle_permissions(method, args);
334        }
335
336        // Note: eval and Function are handled in bootstrap.js for taint tracking.
337        // They are NOT executed (security sandbox) - only observed for analysis.
338        // This is intentional: jsdet analyzes what code WANTS to do, not what it DOES.
339
340        // Generic observation for any unhandled API.
341        self.observe(Observation::ApiCall {
342            api: api.into(),
343            args: args.to_vec(),
344            result: Value::Null,
345        });
346
347        Err(format!("{api} is not defined"))
348    }
349
350    fn get_property(&self, object: &str, property: &str) -> Result<Value, String> {
351        match (object, property) {
352            ("chrome.runtime", "id") => Ok(Value::string(self.state.extension_id.clone())),
353            ("chrome.runtime", "lastError") => Ok(Value::Null),
354            _ => Err(format!("{object}.{property} is not defined")),
355        }
356    }
357
358    fn set_property(&self, object: &str, property: &str, value: &Value) -> Result<(), String> {
359        self.observe(Observation::PropertyWrite {
360            object: object.into(),
361            property: property.into(),
362            value: value.clone(),
363        });
364        Ok(())
365    }
366
367    fn provided_globals(&self) -> Vec<String> {
368        vec!["chrome".into()]
369    }
370
371    fn bootstrap_js(&self) -> String {
372        crate::bootstrap::generate_bootstrap(&self.manifest)
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use jsdet_core::observation::TaintLabel;
380
381    fn make_bridge() -> ChromeExtBridge {
382        let manifest = Manifest::parse(
383            r#"{
384            "name": "Test",
385            "manifest_version": 3,
386            "version": "1.0",
387            "permissions": ["tabs", "storage", "cookies"]
388        }"#,
389        )
390        .unwrap();
391        let profile = AnalysisProfile::default();
392        let state = ExtensionState::default();
393        ChromeExtBridge::new(manifest, profile, state)
394    }
395
396    fn make_bridge_with_profile(profile: AnalysisProfile) -> ChromeExtBridge {
397        let manifest = Manifest::parse(
398            r#"{
399            "name": "Test",
400            "manifest_version": 3,
401            "version": "1.0",
402            "permissions": ["tabs", "storage", "cookies", "webRequest", "alarms"]
403        }"#,
404        )
405        .unwrap();
406        let state = ExtensionState::default();
407        ChromeExtBridge::new(manifest, profile, state)
408    }
409
410    // ============================================================
411    // BASIC BRIDGE TESTS
412    // ============================================================
413
414    #[test]
415    fn tabs_query_returns_tabs() {
416        let bridge = make_bridge();
417        let result = bridge.call("chrome.tabs.query", &[]).unwrap();
418        assert!(matches!(result, Value::Json(..)));
419    }
420
421    #[test]
422    fn storage_set_and_get() {
423        let bridge = make_bridge();
424
425        // Set.
426        bridge
427            .call(
428                "chrome.storage.local.set",
429                &[Value::json(r#"{"key1": "value1"}"#)],
430            )
431            .unwrap();
432
433        // Get.
434        let result = bridge.call("chrome.storage.local.get", &[]).unwrap();
435        if let Value::Json(json, _) = result {
436            assert!(json.contains("key1"));
437        }
438    }
439
440    #[test]
441    fn runtime_get_url() {
442        let bridge = make_bridge();
443        let result = bridge
444            .call("chrome.runtime.getURL", &[Value::string("popup.html")])
445            .unwrap();
446        if let Value::String(url, _) = result {
447            assert!(url.contains("popup.html"));
448            assert!(url.starts_with("chrome-extension://"));
449        }
450    }
451
452    // Note: eval is handled in bootstrap.js for taint tracking only.
453    // It is NOT a public bridge API (LAW 1 - no stubs). The bootstrap
454    // override returns undefined after checking taint - code is observed
455    // but not executed (security sandbox design).
456
457    #[test]
458    fn observations_collected() {
459        let bridge = make_bridge();
460        bridge
461            .call(
462                "chrome.tabs.create",
463                &[Value::json(r#"{"url":"https://evil.com"}"#)],
464            )
465            .unwrap();
466        bridge.call("chrome.cookies.getAll", &[]).unwrap();
467
468        let obs = bridge.take_observations();
469        assert!(obs.len() >= 2);
470    }
471
472    #[test]
473    fn unknown_api_returns_error() {
474        let bridge = make_bridge();
475        assert!(bridge.call("chrome.nonexistent.method", &[]).is_err());
476    }
477
478    #[test]
479    fn provided_globals() {
480        let bridge = make_bridge();
481        assert_eq!(bridge.provided_globals(), vec!["chrome"]);
482    }
483
484    // ============================================================
485    // CHROME.TABS API TESTS
486    // ============================================================
487
488    #[test]
489    fn tabs_query_with_args() {
490        let bridge = make_bridge();
491        let result = bridge
492            .call("chrome.tabs.query", &[Value::json(r#"{"active": true}"#)])
493            .unwrap();
494        assert!(matches!(result, Value::Json(..)));
495    }
496
497    #[test]
498    fn tabs_create() {
499        let bridge = make_bridge();
500        let result = bridge
501            .call(
502                "chrome.tabs.create",
503                &[Value::json(r#"{"url": "https://example.com"}"#)],
504            )
505            .unwrap();
506        assert!(matches!(result, Value::Json(..)));
507
508        let obs = bridge.take_observations();
509        assert!(obs.iter().any(|o| matches!(o,
510            Observation::ApiCall { api, .. } if api == "chrome.tabs.create"
511        )));
512    }
513
514    #[test]
515    fn tabs_update() {
516        let bridge = make_bridge();
517        let result = bridge
518            .call(
519                "chrome.tabs.update",
520                &[
521                    Value::string("1"),
522                    Value::json(r#"{"url": "https://newurl.com"}"#),
523                ],
524            )
525            .unwrap();
526        assert!(matches!(result, Value::Null));
527    }
528
529    #[test]
530    fn tabs_execute_script() {
531        let bridge = make_bridge();
532        let result = bridge
533            .call(
534                "chrome.tabs.executeScript",
535                &[Value::string("1"), Value::json(r#"{"code": "alert(1)"}"#)],
536            )
537            .unwrap();
538        assert!(matches!(result, Value::Null));
539
540        let obs = bridge.take_observations();
541        assert!(obs.iter().any(|o| matches!(o,
542            Observation::ApiCall { api, .. } if api == "chrome.tabs.executeScript"
543        )));
544    }
545
546    #[test]
547    fn tabs_send_message() {
548        let bridge = make_bridge();
549        let result = bridge
550            .call(
551                "chrome.tabs.sendMessage",
552                &[Value::string("1"), Value::json(r#"{"action": "test"}"#)],
553            )
554            .unwrap();
555        assert!(matches!(result, Value::Null));
556    }
557
558    #[test]
559    fn tabs_unknown_method() {
560        let bridge = make_bridge();
561        let result = bridge.call("chrome.tabs.nonexistent", &[]);
562        assert!(result.is_err());
563        assert!(result.unwrap_err().contains("not defined"));
564    }
565
566    // ============================================================
567    // CHROME.COOKIES API TESTS
568    // ============================================================
569
570    #[test]
571    fn cookies_get_all() {
572        let bridge = make_bridge();
573        let result = bridge
574            .call(
575                "chrome.cookies.getAll",
576                &[Value::json(r#"{"domain": "example.com"}"#)],
577            )
578            .unwrap();
579        assert!(matches!(result, Value::Json(..)));
580
581        let obs = bridge.take_observations();
582        assert!(obs.iter().any(|o| matches!(o,
583            Observation::ApiCall { api, .. } if api == "chrome.cookies.getAll"
584        )));
585    }
586
587    #[test]
588    fn cookies_get() {
589        let bridge = make_bridge();
590        let result = bridge
591            .call(
592                "chrome.cookies.get",
593                &[Value::Json(
594                    r#"{"url": "https://example.com", "name": "session"}"#.into(),
595                    TaintLabel::default(),
596                )],
597            )
598            .unwrap();
599        assert!(matches!(result, Value::Json(..)));
600
601        let obs = bridge.take_observations();
602        assert!(obs.iter().any(|o| matches!(o,
603            Observation::ApiCall { api, .. } if api == "chrome.cookies.get"
604        )));
605    }
606
607    #[test]
608    fn cookies_set() {
609        let bridge = make_bridge();
610        let result = bridge
611            .call(
612                "chrome.cookies.set",
613                &[Value::Json(
614                    r#"{"url": "https://example.com", "name": "new", "value": "value"}"#.into(),
615                    TaintLabel::default(),
616                )],
617            )
618            .unwrap();
619        assert!(matches!(result, Value::Null));
620
621        let obs = bridge.take_observations();
622        assert!(obs.iter().any(|o| matches!(o,
623            Observation::ApiCall { api, .. } if api == "chrome.cookies.set"
624        )));
625    }
626
627    #[test]
628    fn cookies_remove() {
629        let bridge = make_bridge();
630        let result = bridge
631            .call(
632                "chrome.cookies.remove",
633                &[Value::Json(
634                    r#"{"url": "https://example.com", "name": "session"}"#.into(),
635                    TaintLabel::default(),
636                )],
637            )
638            .unwrap();
639        assert!(matches!(result, Value::Null));
640
641        let obs = bridge.take_observations();
642        assert!(obs.iter().any(|o| matches!(o,
643            Observation::ApiCall { api, .. } if api == "chrome.cookies.remove"
644        )));
645    }
646
647    #[test]
648    fn cookies_unknown_method() {
649        let bridge = make_bridge();
650        let result = bridge.call("chrome.cookies.nonexistent", &[]);
651        assert!(result.is_err());
652    }
653
654    // ============================================================
655    // CHROME.STORAGE API TESTS
656    // ============================================================
657
658    #[test]
659    fn storage_local_get_empty() {
660        let bridge = make_bridge();
661        let result = bridge.call("chrome.storage.local.get", &[]).unwrap();
662        assert!(matches!(result, Value::Json(..)));
663    }
664
665    #[test]
666    fn storage_local_get_with_keys() {
667        let bridge = make_bridge();
668        let result = bridge
669            .call(
670                "chrome.storage.local.get",
671                &[Value::json(r#"["key1", "key2"]"#)],
672            )
673            .unwrap();
674        assert!(matches!(result, Value::Json(..)));
675    }
676
677    #[test]
678    fn storage_local_set_and_get_roundtrip() {
679        let bridge = make_bridge();
680
681        // Set a value
682        bridge
683            .call(
684                "chrome.storage.local.set",
685                &[Value::json(r#"{"testkey": "testvalue"}"#)],
686            )
687            .unwrap();
688
689        // Get all values
690        let result = bridge.call("chrome.storage.local.get", &[]).unwrap();
691        if let Value::Json(json, _) = result {
692            assert!(json.contains("testkey"));
693            assert!(json.contains("testvalue"));
694        }
695    }
696
697    #[test]
698    fn storage_local_remove() {
699        let bridge = make_bridge();
700        let result = bridge
701            .call("chrome.storage.local.remove", &[Value::json(r#""key1""#)])
702            .unwrap();
703        assert!(matches!(result, Value::Null));
704    }
705
706    #[test]
707    fn storage_local_clear() {
708        let bridge = make_bridge();
709
710        // Set a value first
711        bridge
712            .call(
713                "chrome.storage.local.set",
714                &[Value::json(r#"{"temp": "value"}"#)],
715            )
716            .unwrap();
717
718        // Clear
719        let result = bridge.call("chrome.storage.local.clear", &[]).unwrap();
720        assert!(matches!(result, Value::Null));
721
722        // Verify it's cleared
723        let get_result = bridge.call("chrome.storage.local.get", &[]).unwrap();
724        if let Value::Json(json, _) = get_result {
725            assert_eq!(json, "{}");
726        }
727    }
728
729    #[test]
730    fn storage_sync_get() {
731        let bridge = make_bridge();
732        let result = bridge.call("chrome.storage.sync.get", &[]).unwrap();
733        assert!(matches!(result, Value::Json(..)));
734    }
735
736    #[test]
737    fn storage_sync_set() {
738        let bridge = make_bridge();
739        let result = bridge
740            .call(
741                "chrome.storage.sync.set",
742                &[Value::json(r#"{"synckey": "syncvalue"}"#)],
743            )
744            .unwrap();
745        assert!(matches!(result, Value::Null));
746    }
747
748    #[test]
749    fn storage_sync_remove() {
750        let bridge = make_bridge();
751        let result = bridge
752            .call("chrome.storage.sync.remove", &[Value::json(r#""key""#)])
753            .unwrap();
754        assert!(matches!(result, Value::Null));
755    }
756
757    #[test]
758    fn storage_sync_clear() {
759        let bridge = make_bridge();
760        let result = bridge.call("chrome.storage.sync.clear", &[]).unwrap();
761        assert!(matches!(result, Value::Null));
762    }
763
764    #[test]
765    fn storage_shortcut_local_get() {
766        // Test "get" without "local." prefix (defaults to local)
767        let bridge = make_bridge();
768        let result = bridge.call("chrome.storage.get", &[]).unwrap();
769        assert!(matches!(result, Value::Json(..)));
770    }
771
772    #[test]
773    fn storage_unknown_area() {
774        let bridge = make_bridge();
775        // Unknown storage area should default to local
776        let result = bridge.call("chrome.storage.unknown.get", &[]);
777        // This currently defaults to local, but let's document the behavior
778        assert!(result.is_ok() || result.is_err());
779    }
780
781    // ============================================================
782    // CHROME.RUNTIME API TESTS
783    // ============================================================
784
785    #[test]
786    fn runtime_get_url_empty_path() {
787        let bridge = make_bridge();
788        let result = bridge.call("chrome.runtime.getURL", &[]).unwrap();
789        if let Value::String(url, _) = result {
790            assert!(url.starts_with("chrome-extension://"));
791            assert!(url.ends_with("/"));
792        }
793    }
794
795    #[test]
796    fn runtime_get_url_with_path() {
797        let bridge = make_bridge();
798        let result = bridge
799            .call("chrome.runtime.getURL", &[Value::string("js/content.js")])
800            .unwrap();
801        if let Value::String(url, _) = result {
802            assert!(url.contains("js/content.js"));
803        }
804    }
805
806    #[test]
807    fn runtime_send_message() {
808        let bridge = make_bridge();
809        let result = bridge
810            .call(
811                "chrome.runtime.sendMessage",
812                &[Value::json(r#"{"action": "test"}"#)],
813            )
814            .unwrap();
815        assert!(matches!(result, Value::Null));
816
817        let obs = bridge.take_observations();
818        assert!(obs.iter().any(|o| matches!(o,
819            Observation::ApiCall { api, .. } if api == "chrome.runtime.sendMessage"
820        )));
821    }
822
823    #[test]
824    fn runtime_get_manifest() {
825        let bridge = make_bridge();
826        let result = bridge.call("chrome.runtime.getManifest", &[]).unwrap();
827        if let Value::Json(json, _) = result {
828            assert!(json.contains("Test"));
829            assert!(json.contains("manifest_version"));
830        }
831    }
832
833    #[test]
834    fn runtime_id() {
835        let bridge = make_bridge();
836        let result = bridge.call("chrome.runtime.id", &[]).unwrap();
837        if let Value::String(id, _) = result {
838            assert!(!id.is_empty());
839        }
840    }
841
842    #[test]
843    fn runtime_unknown_method() {
844        let bridge = make_bridge();
845        let result = bridge.call("chrome.runtime.nonexistent", &[]);
846        assert!(result.is_err());
847    }
848
849    // ============================================================
850    // CHROME.SCRIPTING API TESTS
851    // ============================================================
852
853    #[test]
854    fn scripting_execute_script() {
855        let bridge = make_bridge();
856        let result = bridge
857            .call(
858                "chrome.scripting.executeScript",
859                &[Value::Json(
860                    r#"{"target": {"tabId": 1}, "func": "() => {}"}"#.into(),
861                    TaintLabel::default(),
862                )],
863            )
864            .unwrap();
865        assert!(matches!(result, Value::Null));
866
867        let obs = bridge.take_observations();
868        assert!(obs.iter().any(|o| matches!(o,
869            Observation::ApiCall { api, .. } if api == "chrome.scripting.executeScript"
870        )));
871    }
872
873    #[test]
874    fn scripting_insert_css() {
875        let bridge = make_bridge();
876        let result = bridge
877            .call(
878                "chrome.scripting.insertCSS",
879                &[Value::Json(
880                    r#"{"target": {"tabId": 1}, "css": "body{}"}"#.into(),
881                    TaintLabel::default(),
882                )],
883            )
884            .unwrap();
885        assert!(matches!(result, Value::Null));
886
887        let obs = bridge.take_observations();
888        assert!(obs.iter().any(|o| matches!(o,
889            Observation::ApiCall { api, .. } if api == "chrome.scripting.insertCSS"
890        )));
891    }
892
893    #[test]
894    fn scripting_register_content_scripts() {
895        let bridge = make_bridge();
896        let result = bridge
897            .call(
898                "chrome.scripting.registerContentScripts",
899                &[Value::Json(
900                    r#"[{"id": "script1", "matches": ["<all_urls>"], "js": ["content.js"]}]"#
901                        .into(),
902                    TaintLabel::default(),
903                )],
904            )
905            .unwrap();
906        assert!(matches!(result, Value::Null));
907    }
908
909    #[test]
910    fn scripting_unknown_method() {
911        let bridge = make_bridge();
912        let result = bridge.call("chrome.scripting.nonexistent", &[]);
913        assert!(result.is_err());
914    }
915
916    // ============================================================
917    // CHROME.WEBREQUEST API TESTS
918    // ============================================================
919
920    #[test]
921    fn webrequest_on_before_request() {
922        let bridge = make_bridge();
923        let result = bridge
924            .call("chrome.webRequest.onBeforeRequest.addListener", &[])
925            .unwrap();
926        assert!(matches!(result, Value::Null));
927
928        let obs = bridge.take_observations();
929        assert!(obs.iter().any(|o| matches!(o,
930            Observation::ApiCall { api, .. } if api == "chrome.webRequest.onBeforeRequest.addListener"
931        )));
932    }
933
934    #[test]
935    fn webrequest_on_before_send_headers() {
936        let bridge = make_bridge();
937        let result = bridge
938            .call("chrome.webRequest.onBeforeSendHeaders.addListener", &[])
939            .unwrap();
940        assert!(matches!(result, Value::Null));
941    }
942
943    #[test]
944    fn webrequest_any_method() {
945        // webRequest.* accepts any method
946        let bridge = make_bridge();
947        let result = bridge
948            .call("chrome.webRequest.onCompleted.addListener", &[])
949            .unwrap();
950        assert!(matches!(result, Value::Null));
951
952        let result = bridge
953            .call("chrome.webRequest.onErrorOccurred.addListener", &[])
954            .unwrap();
955        assert!(matches!(result, Value::Null));
956    }
957
958    // ============================================================
959    // CHROME.ALARMS API TESTS
960    // ============================================================
961
962    #[test]
963    fn alarms_create() {
964        let bridge = make_bridge();
965        let result = bridge
966            .call(
967                "chrome.alarms.create",
968                &[
969                    Value::string("alarm1"),
970                    Value::json(r#"{"delayInMinutes": 5}"#),
971                ],
972            )
973            .unwrap();
974        assert!(matches!(result, Value::Null));
975
976        let obs = bridge.take_observations();
977        assert!(obs.iter().any(|o| matches!(o,
978            Observation::ApiCall { api, .. } if api == "chrome.alarms.create"
979        )));
980    }
981
982    #[test]
983    fn alarms_get() {
984        let bridge = make_bridge();
985        let result = bridge
986            .call("chrome.alarms.get", &[Value::string("alarm1")])
987            .unwrap();
988        assert!(matches!(result, Value::Json(..)));
989    }
990
991    #[test]
992    fn alarms_get_all() {
993        let bridge = make_bridge();
994        let result = bridge.call("chrome.alarms.getAll", &[]).unwrap();
995        assert!(matches!(result, Value::Json(..)));
996    }
997
998    #[test]
999    fn alarms_clear() {
1000        let bridge = make_bridge();
1001        let result = bridge
1002            .call("chrome.alarms.clear", &[Value::string("alarm1")])
1003            .unwrap();
1004        assert!(matches!(result, Value::Json(..)));
1005    }
1006
1007    #[test]
1008    fn alarms_clear_all() {
1009        let bridge = make_bridge();
1010        let result = bridge.call("chrome.alarms.clearAll", &[]).unwrap();
1011        assert!(matches!(result, Value::Json(..)));
1012    }
1013
1014    #[test]
1015    fn alarms_unknown_method() {
1016        let bridge = make_bridge();
1017        let result = bridge.call("chrome.alarms.nonexistent", &[]);
1018        assert!(result.is_err());
1019    }
1020
1021    // ============================================================
1022    // CHROME.PERMISSIONS API TESTS
1023    // ============================================================
1024
1025    #[test]
1026    fn permissions_get_all() {
1027        let bridge = make_bridge();
1028        let result = bridge.call("chrome.permissions.getAll", &[]).unwrap();
1029        if let Value::Json(json, _) = result {
1030            assert!(json.contains("permissions"));
1031            assert!(json.contains("tabs")); // Our manifest has tabs permission
1032        }
1033    }
1034
1035    #[test]
1036    fn permissions_contains() {
1037        let bridge = make_bridge();
1038        let result = bridge
1039            .call(
1040                "chrome.permissions.contains",
1041                &[Value::json(r#"{"permissions": ["tabs"]}"#)],
1042            )
1043            .unwrap();
1044        if let Value::Bool(has_perm) = result {
1045            assert!(has_perm); // Always true in sandbox
1046        }
1047    }
1048
1049    #[test]
1050    fn permissions_request() {
1051        let bridge = make_bridge();
1052        let result = bridge
1053            .call(
1054                "chrome.permissions.request",
1055                &[Value::json(r#"{"permissions": ["history"]}"#)],
1056            )
1057            .unwrap();
1058        if let Value::Bool(granted) = result {
1059            assert!(granted); // Always true in sandbox
1060        }
1061    }
1062
1063    #[test]
1064    fn permissions_unknown_method() {
1065        let bridge = make_bridge();
1066        let result = bridge.call("chrome.permissions.nonexistent", &[]);
1067        assert!(result.is_err());
1068    }
1069
1070    // ============================================================
1071    // EVAL / FUNCTION TESTS
1072    // ============================================================
1073
1074    #[test]
1075    fn eval_with_csp_allowing() {
1076        let manifest = Manifest::parse(
1077            r#"{
1078            "name": "Permissive",
1079            "manifest_version": 3,
1080            "version": "1.0",
1081            "content_security_policy": {
1082                "extension_pages": "script-src 'self' 'unsafe-eval'"
1083            }
1084        }"#,
1085        )
1086        .unwrap();
1087        let bridge = ChromeExtBridge::new(
1088            manifest,
1089            AnalysisProfile::default(),
1090            ExtensionState::default(),
1091        );
1092
1093        // This should NOT return CSP error, but will return generic "not defined" since we don't actually eval
1094        let result = bridge.call("eval", &[Value::string("1+1")]);
1095        // The result is Ok with observation recorded, or Err for other reasons
1096        // but NOT the CSP error
1097        if let Err(e) = result {
1098            assert!(!e.contains("unsafe-eval"));
1099        }
1100
1101        let obs = bridge.take_observations();
1102        assert!(
1103            obs.iter()
1104                .any(|o| matches!(o, Observation::DynamicCodeExec { .. }))
1105        );
1106    }
1107
1108    #[test]
1109    fn function_constructor_blocked_by_csp() {
1110        let manifest = Manifest::parse(
1111            r#"{
1112            "name": "Strict",
1113            "manifest_version": 3,
1114            "version": "1.0",
1115            "content_security_policy": {
1116                "extension_pages": "script-src 'self'"
1117            }
1118        }"#,
1119        )
1120        .unwrap();
1121        let bridge = ChromeExtBridge::new(
1122            manifest,
1123            AnalysisProfile::default(),
1124            ExtensionState::default(),
1125        );
1126
1127        let result = bridge.call("Function", &[Value::string("return 1")]);
1128        assert!(result.is_err());
1129        assert!(result.unwrap_err().contains("unsafe-eval"));
1130    }
1131
1132    #[test]
1133    fn function_constructor_allowed() {
1134        let bridge = make_bridge();
1135        let _result = bridge.call("Function", &[Value::string("return 1")]);
1136        // Observation recorded regardless
1137        let obs = bridge.take_observations();
1138        assert!(
1139            obs.iter()
1140                .any(|o| matches!(o, Observation::DynamicCodeExec { .. }))
1141        );
1142    }
1143
1144    #[test]
1145    fn eval_code_preview_truncated() {
1146        let bridge = make_bridge();
1147        let long_code = "x".repeat(1000);
1148        let _result = bridge.call(
1149            "eval",
1150            &[Value::String(long_code.clone(), TaintLabel::default())],
1151        );
1152
1153        let obs = bridge.take_observations();
1154        // Check that observation was recorded
1155        let found = obs
1156            .iter()
1157            .any(|o| matches!(o, Observation::DynamicCodeExec { .. }));
1158        assert!(found);
1159
1160        // Check that preview is truncated (implementation uses take(200))
1161        let preview = obs.iter().find_map(|o| {
1162            if let Observation::DynamicCodeExec { code_preview, .. } = o {
1163                Some(code_preview.clone())
1164            } else {
1165                None
1166            }
1167        });
1168        // The preview should be truncated to 200 chars
1169        // NOTE: The preview includes "String(...)" wrapper from the Value display
1170        assert!(preview.is_some());
1171    }
1172
1173    // ============================================================
1174    // OBSERVATION TESTS
1175    // ============================================================
1176
1177    #[test]
1178    fn take_observations_drains_list() {
1179        let bridge = make_bridge();
1180
1181        // Make some API calls
1182        bridge.call("chrome.tabs.query", &[]).unwrap();
1183        bridge.call("chrome.cookies.getAll", &[]).unwrap();
1184
1185        // First take should return observations
1186        let obs1 = bridge.take_observations();
1187        assert!(!obs1.is_empty());
1188
1189        // Second take should be empty (drained)
1190        let obs2 = bridge.take_observations();
1191        assert!(obs2.is_empty());
1192    }
1193
1194    #[test]
1195    fn take_observations_returns_correct_count() {
1196        let bridge = make_bridge();
1197
1198        bridge.call("chrome.tabs.create", &[]).unwrap();
1199        bridge.call("chrome.tabs.update", &[]).unwrap();
1200        bridge.call("chrome.tabs.sendMessage", &[]).unwrap();
1201
1202        let obs = bridge.take_observations();
1203        assert!(obs.len() >= 3);
1204    }
1205
1206    #[test]
1207    fn observations_include_args() {
1208        let bridge = make_bridge();
1209
1210        bridge
1211            .call(
1212                "chrome.tabs.create",
1213                &[Value::json(r#"{"url": "https://example.com"}"#)],
1214            )
1215            .unwrap();
1216
1217        let obs = bridge.take_observations();
1218        // Check that ApiCall observation was recorded with args
1219        let found = obs.iter().any(|o| {
1220            if let Observation::ApiCall { api, args, .. } = o {
1221                api == "chrome.tabs.create" && !args.is_empty()
1222            } else {
1223                false
1224            }
1225        });
1226        assert!(found);
1227    }
1228
1229    // ============================================================
1230    // PROPERTY TESTS
1231    // ============================================================
1232
1233    #[test]
1234    fn get_property_runtime_id() {
1235        let bridge = make_bridge();
1236        let result = bridge.get_property("chrome.runtime", "id").unwrap();
1237        if let Value::String(id, _) = result {
1238            assert!(!id.is_empty());
1239        }
1240    }
1241
1242    #[test]
1243    fn get_property_runtime_last_error() {
1244        let bridge = make_bridge();
1245        let result = bridge.get_property("chrome.runtime", "lastError").unwrap();
1246        assert!(matches!(result, Value::Null));
1247    }
1248
1249    #[test]
1250    fn get_property_unknown() {
1251        let bridge = make_bridge();
1252        let result = bridge.get_property("chrome.unknown", "property");
1253        assert!(result.is_err());
1254    }
1255
1256    #[test]
1257    fn get_property_unknown_property() {
1258        let bridge = make_bridge();
1259        let result = bridge.get_property("chrome.runtime", "unknownProperty");
1260        assert!(result.is_err());
1261    }
1262
1263    #[test]
1264    fn set_property_records_observation() {
1265        let bridge = make_bridge();
1266
1267        bridge
1268            .set_property("chrome.storage.local", "myKey", &Value::string("myValue"))
1269            .unwrap();
1270
1271        let obs = bridge.take_observations();
1272        let found = obs.iter().any(|o| {
1273            matches!(o,
1274                Observation::PropertyWrite { object, property, .. }
1275                if object == "chrome.storage.local" && property == "myKey"
1276            )
1277        });
1278        assert!(found);
1279    }
1280
1281    #[test]
1282    fn set_property_any_object() {
1283        let bridge = make_bridge();
1284        // Should work with any object/property
1285        let result = bridge.set_property("some.object", "prop", &Value::Null);
1286        assert!(result.is_ok());
1287    }
1288
1289    // ============================================================
1290    // BOOTSTRAP JS TESTS
1291    // ============================================================
1292
1293    #[test]
1294    fn bootstrap_js_returns_non_empty() {
1295        let bridge = make_bridge();
1296        let js = bridge.bootstrap_js();
1297        assert!(!js.is_empty());
1298        assert!(js.len() > 100);
1299    }
1300
1301    #[test]
1302    fn bootstrap_js_contains_chrome() {
1303        let bridge = make_bridge();
1304        let js = bridge.bootstrap_js();
1305        assert!(js.contains("chrome"));
1306    }
1307
1308    // ============================================================
1309    // UNKNOWN API TESTS
1310    // ============================================================
1311
1312    #[test]
1313    fn unknown_chrome_namespace() {
1314        let bridge = make_bridge();
1315        let result = bridge.call("chrome.totallyunknown.something", &[]);
1316        assert!(result.is_err());
1317        assert!(result.unwrap_err().contains("not defined"));
1318    }
1319
1320    #[test]
1321    fn unknown_top_level() {
1322        let bridge = make_bridge();
1323        let result = bridge.call("notchrome.something", &[]);
1324        assert!(result.is_err());
1325    }
1326
1327    #[test]
1328    fn empty_api_name() {
1329        let bridge = make_bridge();
1330        let result = bridge.call("", &[]);
1331        assert!(result.is_err());
1332    }
1333
1334    // ============================================================
1335    // PROFILE INTEGRATION TESTS (Sources/Sinks)
1336    // ============================================================
1337
1338    #[test]
1339    fn sink_detection_tabs_update() {
1340        let profile = AnalysisProfile::parse(
1341            r#"
1342[[sinks]]
1343api = "chrome.tabs.update"
1344dangerous_arg = 1
1345cwe = "CWE-601"
1346severity = "high"
1347"#,
1348        )
1349        .unwrap();
1350
1351        let bridge = make_bridge_with_profile(profile);
1352        bridge
1353            .call(
1354                "chrome.tabs.update",
1355                &[
1356                    Value::string("1"),
1357                    Value::json(r#"{"url": "https://evil.com"}"#),
1358                ],
1359            )
1360            .unwrap();
1361
1362        let obs = bridge.take_observations();
1363        let found = obs.iter().any(|o| {
1364            matches!(o,
1365                Observation::ApiCall { api, .. } if api.contains("SINK:")
1366            )
1367        });
1368        assert!(found);
1369    }
1370
1371    #[test]
1372    fn sink_detection_tabs_execute_script() {
1373        let profile = AnalysisProfile::parse(
1374            r#"
1375[[sinks]]
1376api = "chrome.tabs.executeScript"
1377cwe = "CWE-94"
1378"#,
1379        )
1380        .unwrap();
1381
1382        let bridge = make_bridge_with_profile(profile);
1383        bridge
1384            .call(
1385                "chrome.tabs.executeScript",
1386                &[Value::string("1"), Value::json(r#"{"code": "alert(1)"}"#)],
1387            )
1388            .unwrap();
1389
1390        let obs = bridge.take_observations();
1391        let found = obs.iter().any(|o| {
1392            matches!(o,
1393                Observation::ApiCall { api, .. } if api.contains("SINK:") && api.contains("CWE-94")
1394            )
1395        });
1396        assert!(found);
1397    }
1398
1399    #[test]
1400    fn sink_detection_scripting_execute_script() {
1401        let profile = AnalysisProfile::parse(
1402            r#"
1403[[sinks]]
1404api = "chrome.scripting.executeScript"
1405cwe = "CWE-94"
1406"#,
1407        )
1408        .unwrap();
1409
1410        let bridge = make_bridge_with_profile(profile);
1411        bridge.call("chrome.scripting.executeScript", &[]).unwrap();
1412
1413        let obs = bridge.take_observations();
1414        let found = obs.iter().any(|o| {
1415            matches!(o,
1416                Observation::ApiCall { api, .. } if api.contains("SINK:")
1417            )
1418        });
1419        assert!(found);
1420    }
1421
1422    #[test]
1423    fn sink_detection_scripting_insert_css() {
1424        let profile = AnalysisProfile::parse(
1425            r#"
1426[[sinks]]
1427api = "chrome.scripting.insertCSS"
1428cwe = "CWE-79"
1429"#,
1430        )
1431        .unwrap();
1432
1433        let bridge = make_bridge_with_profile(profile);
1434        bridge.call("chrome.scripting.insertCSS", &[]).unwrap();
1435
1436        let obs = bridge.take_observations();
1437        let found = obs.iter().any(|o| {
1438            matches!(o,
1439                Observation::ApiCall { api, .. } if api.contains("SINK:")
1440            )
1441        });
1442        assert!(found);
1443    }
1444
1445    #[test]
1446    fn source_detection_send_message() {
1447        let profile = AnalysisProfile::parse(
1448            r#"
1449[[sources]]
1450api = "chrome.runtime.sendMessage"
1451taint_id = 1
1452"#,
1453        )
1454        .unwrap();
1455
1456        let bridge = make_bridge_with_profile(profile);
1457        bridge
1458            .call("chrome.runtime.sendMessage", &[Value::Null])
1459            .unwrap();
1460
1461        let obs = bridge.take_observations();
1462        let found = obs.iter().any(|o| {
1463            matches!(o,
1464                Observation::ApiCall { api, .. } if api.contains("SOURCE:")
1465            )
1466        });
1467        assert!(found);
1468    }
1469
1470    #[test]
1471    fn no_sink_detection_for_unconfigured_api() {
1472        let profile = AnalysisProfile::default();
1473        let bridge = make_bridge_with_profile(profile);
1474
1475        bridge.call("chrome.tabs.update", &[]).unwrap();
1476
1477        let obs = bridge.take_observations();
1478        let found = obs.iter().any(|o| {
1479            matches!(o,
1480                Observation::ApiCall { api, .. } if api.contains("SINK:")
1481            )
1482        });
1483        assert!(!found);
1484    }
1485
1486    /// Integration test: load extension handler in WASM sandbox,
1487    /// fire message with payload, detect eval() sink via bridge.
1488    #[test]
1489    fn wasm_sandbox_detects_eval_via_bridge() {
1490        use jsdet_core::bridge::Bridge;
1491        use jsdet_core::{CompiledModule, PersistentSandbox, SandboxConfig};
1492        use std::sync::Arc;
1493
1494        let module = CompiledModule::new().unwrap();
1495        let manifest = Manifest::parse(r#"{"name":"T","manifest_version":3,"version":"1.0","background":{"service_worker":"sw.js"}}"#).unwrap();
1496        let profile = AnalysisProfile::default();
1497        let state = crate::state::ExtensionState::default_with_id("test");
1498        let bridge: Arc<dyn Bridge> =
1499            Arc::new(ChromeExtBridge::new(manifest.clone(), profile, state));
1500        let config = SandboxConfig::default();
1501
1502        let mut sb = PersistentSandbox::new(&module, bridge, &config).unwrap();
1503
1504        let bootstrap = crate::bootstrap::generate_bootstrap(&manifest);
1505        let handler = r#"
1506            chrome.runtime.onMessage.addListener(function(msg) {
1507                eval(msg.code);
1508            });
1509        "#
1510        .to_string();
1511
1512        sb.load(&[bootstrap, handler]).unwrap();
1513
1514        let obs = sb.eval_only(
1515            r#"
1516            chrome.runtime._fireOnMessage(
1517                {code: "alert(SLN_TEST_123)"},
1518                {tab: {id: 1}, id: "test"},
1519                function() {}
1520            );
1521        "#,
1522        );
1523
1524        eprintln!("Observations ({}):", obs.len());
1525        for o in &obs {
1526            eprintln!("  {:?}", o);
1527        }
1528
1529        let has_eval = obs.iter().any(|o| match o {
1530            jsdet_core::Observation::ApiCall { api, .. } => api == "eval",
1531            _ => false,
1532        });
1533        assert!(
1534            has_eval,
1535            "should detect eval() call. Got {} observations",
1536            obs.len()
1537        );
1538    }
1539}