Skip to main content

jsdet_chrome_ext/
state.rs

1//! Simulated Chrome extension state.
2//!
3//! Provides fake but realistic state for the chrome.* APIs:
4//! tabs, cookies, storage, alarms. The state is controllable by
5//! the security researcher — inject specific cookies, tabs, or
6//! storage values to test how the extension handles them.
7
8use std::collections::HashMap;
9use std::sync::{Arc, Mutex};
10
11use jsdet_core::observation::Value;
12
13/// Simulated browser state that chrome.* APIs operate on.
14#[derive(Clone, Debug)]
15pub struct ExtensionState {
16    /// Simulated tab list.
17    pub tabs: Arc<Mutex<Vec<Tab>>>,
18    /// Simulated cookie jar.
19    pub cookies: Arc<Mutex<Vec<Cookie>>>,
20    /// Simulated chrome.storage.local.
21    pub storage_local: Arc<Mutex<HashMap<String, String>>>,
22    /// Simulated chrome.storage.sync.
23    pub storage_sync: Arc<Mutex<HashMap<String, String>>>,
24    /// Simulated alarms.
25    pub alarms: Arc<Mutex<Vec<Alarm>>>,
26    /// Message queue (from content scripts or external websites).
27    pub pending_messages: Arc<Mutex<Vec<PendingMessage>>>,
28    /// Extension's own ID.
29    pub extension_id: String,
30}
31
32/// A simulated browser tab.
33#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
34pub struct Tab {
35    pub id: u32,
36    pub url: String,
37    pub title: String,
38    pub active: bool,
39    pub index: u32,
40}
41
42/// A simulated cookie.
43#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
44pub struct Cookie {
45    pub name: String,
46    pub value: String,
47    pub domain: String,
48    pub path: String,
49    pub secure: bool,
50    pub http_only: bool,
51}
52
53/// A simulated alarm.
54#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
55pub struct Alarm {
56    pub name: String,
57    pub scheduled_time: f64,
58    pub period_in_minutes: Option<f64>,
59}
60
61/// A message waiting to be delivered to the extension.
62#[derive(Clone, Debug)]
63pub struct PendingMessage {
64    /// Message payload.
65    pub data: Value,
66    /// Sender info (for origin checking).
67    pub sender_origin: Option<String>,
68    /// Whether this is from an external website (vs content script).
69    pub is_external: bool,
70    /// Sender tab ID (if from a content script).
71    pub sender_tab_id: Option<u32>,
72}
73
74impl Default for ExtensionState {
75    fn default() -> Self {
76        Self {
77            tabs: Arc::new(Mutex::new(vec![Tab {
78                id: 1,
79                url: "https://example.com".into(),
80                title: "Example".into(),
81                active: true,
82                index: 0,
83            }])),
84            cookies: Arc::new(Mutex::new(vec![Cookie {
85                name: "session".into(),
86                value: "abc123".into(),
87                domain: ".example.com".into(),
88                path: "/".into(),
89                secure: true,
90                http_only: true,
91            }])),
92            storage_local: Arc::new(Mutex::new(HashMap::new())),
93            storage_sync: Arc::new(Mutex::new(HashMap::new())),
94            alarms: Arc::new(Mutex::new(Vec::new())),
95            pending_messages: Arc::new(Mutex::new(Vec::new())),
96            extension_id: "test-extension-id".into(),
97        }
98    }
99}
100
101impl ExtensionState {
102    /// Create default state with a specific extension ID.
103    pub fn default_with_id(id: &str) -> Self {
104        Self {
105            extension_id: id.to_string(),
106            ..Self::default()
107        }
108    }
109
110    /// Create state with custom tab list.
111    pub fn with_tabs(mut self, tabs: Vec<Tab>) -> Self {
112        self.tabs = Arc::new(Mutex::new(tabs));
113        self
114    }
115
116    /// Create state with custom cookies.
117    pub fn with_cookies(mut self, cookies: Vec<Cookie>) -> Self {
118        self.cookies = Arc::new(Mutex::new(cookies));
119        self
120    }
121
122    /// Pre-load storage with values.
123    pub fn with_storage(mut self, data: HashMap<String, String>) -> Self {
124        self.storage_local = Arc::new(Mutex::new(data));
125        self
126    }
127
128    /// Queue a message for delivery to the extension.
129    /// CRITICAL FIX: Handle poisoned mutex gracefully - drop message if mutex is poisoned.
130    pub fn queue_message(&self, msg: PendingMessage) {
131        if let Ok(mut guard) = self.pending_messages.lock() {
132            guard.push(msg);
133        }
134    }
135
136    /// Take all pending messages (drains the queue).
137    /// CRITICAL FIX: Handle poisoned mutex - recover data even if poisoned.
138    pub fn take_messages(&self) -> Vec<PendingMessage> {
139        let mut guard = self
140            .pending_messages
141            .lock()
142            .unwrap_or_else(std::sync::PoisonError::into_inner);
143        std::mem::take(&mut *guard)
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    // ============================================================
152    // DEFAULT STATE TESTS
153    // ============================================================
154
155    #[test]
156    fn default_state_has_one_tab() {
157        let state = ExtensionState::default();
158        let tabs = state.tabs.lock().unwrap();
159        assert_eq!(tabs.len(), 1);
160        assert!(tabs[0].active);
161    }
162
163    #[test]
164    fn default_state_has_session_cookie() {
165        let state = ExtensionState::default();
166        let cookies = state.cookies.lock().unwrap();
167        assert_eq!(cookies.len(), 1);
168        assert_eq!(cookies[0].name, "session");
169    }
170
171    #[test]
172    fn default_state_cookie_properties() {
173        let state = ExtensionState::default();
174        let cookies = state.cookies.lock().unwrap();
175        let cookie = &cookies[0];
176        assert_eq!(cookie.value, "abc123");
177        assert_eq!(cookie.domain, ".example.com");
178        assert_eq!(cookie.path, "/");
179        assert!(cookie.secure);
180        assert!(cookie.http_only);
181    }
182
183    #[test]
184    fn default_state_tab_properties() {
185        let state = ExtensionState::default();
186        let tabs = state.tabs.lock().unwrap();
187        let tab = &tabs[0];
188        assert_eq!(tab.id, 1);
189        assert_eq!(tab.url, "https://example.com");
190        assert_eq!(tab.title, "Example");
191        assert_eq!(tab.index, 0);
192        assert!(tab.active);
193    }
194
195    #[test]
196    fn default_state_storage_is_empty() {
197        let state = ExtensionState::default();
198        assert!(state.storage_local.lock().unwrap().is_empty());
199        assert!(state.storage_sync.lock().unwrap().is_empty());
200    }
201
202    #[test]
203    fn default_state_alarms_is_empty() {
204        let state = ExtensionState::default();
205        assert!(state.alarms.lock().unwrap().is_empty());
206    }
207
208    #[test]
209    fn default_state_pending_messages_is_empty() {
210        let state = ExtensionState::default();
211        assert!(state.pending_messages.lock().unwrap().is_empty());
212    }
213
214    #[test]
215    fn default_state_extension_id() {
216        let state = ExtensionState::default();
217        assert_eq!(state.extension_id, "test-extension-id");
218    }
219
220    // ============================================================
221    // MESSAGE QUEUE TESTS
222    // ============================================================
223
224    #[test]
225    fn message_queue() {
226        let state = ExtensionState::default();
227        state.queue_message(PendingMessage {
228            data: Value::string("hello"),
229            sender_origin: Some("https://evil.com".into()),
230            is_external: true,
231            sender_tab_id: None,
232        });
233
234        let msgs = state.take_messages();
235        assert_eq!(msgs.len(), 1);
236        assert!(msgs[0].is_external);
237
238        // Queue should be empty after take.
239        assert!(state.take_messages().is_empty());
240    }
241
242    #[test]
243    fn message_queue_multiple_messages() {
244        let state = ExtensionState::default();
245
246        state.queue_message(PendingMessage {
247            data: Value::string("msg1"),
248            sender_origin: Some("https://a.com".into()),
249            is_external: true,
250            sender_tab_id: None,
251        });
252        state.queue_message(PendingMessage {
253            data: Value::string("msg2"),
254            sender_origin: Some("https://b.com".into()),
255            is_external: false,
256            sender_tab_id: Some(1),
257        });
258        state.queue_message(PendingMessage {
259            data: Value::json(r#"{"action": "test"}"#),
260            sender_origin: None,
261            is_external: false,
262            sender_tab_id: Some(2),
263        });
264
265        let msgs = state.take_messages();
266        assert_eq!(msgs.len(), 3);
267        assert!(msgs[0].is_external);
268        assert!(!msgs[1].is_external);
269        assert_eq!(msgs[1].sender_tab_id, Some(1));
270    }
271
272    #[test]
273    fn message_queue_take_drains_queue() {
274        let state = ExtensionState::default();
275
276        for i in 0..5 {
277            state.queue_message(PendingMessage {
278                data: Value::string(format!("msg{}", i)),
279                sender_origin: None,
280                is_external: false,
281                sender_tab_id: None,
282            });
283        }
284
285        let msgs1 = state.take_messages();
286        assert_eq!(msgs1.len(), 5);
287
288        let msgs2 = state.take_messages();
289        assert!(msgs2.is_empty());
290
291        let msgs3 = state.take_messages();
292        assert!(msgs3.is_empty());
293    }
294
295    #[test]
296    fn message_queue_order_preserved() {
297        let state = ExtensionState::default();
298
299        state.queue_message(PendingMessage {
300            data: Value::string("first"),
301            sender_origin: None,
302            is_external: false,
303            sender_tab_id: None,
304        });
305        state.queue_message(PendingMessage {
306            data: Value::string("second"),
307            sender_origin: None,
308            is_external: false,
309            sender_tab_id: None,
310        });
311        state.queue_message(PendingMessage {
312            data: Value::string("third"),
313            sender_origin: None,
314            is_external: false,
315            sender_tab_id: None,
316        });
317
318        let msgs = state.take_messages();
319        assert_eq!(msgs[0].data, Value::string("first"));
320        assert_eq!(msgs[1].data, Value::string("second"));
321        assert_eq!(msgs[2].data, Value::string("third"));
322    }
323
324    #[test]
325    fn message_queue_external_vs_internal() {
326        let state = ExtensionState::default();
327
328        state.queue_message(PendingMessage {
329            data: Value::Null,
330            sender_origin: Some("https://external.com".into()),
331            is_external: true,
332            sender_tab_id: None,
333        });
334        state.queue_message(PendingMessage {
335            data: Value::Null,
336            sender_origin: None,
337            is_external: false,
338            sender_tab_id: Some(1),
339        });
340
341        let msgs = state.take_messages();
342        assert!(msgs[0].is_external);
343        assert!(!msgs[1].is_external);
344        assert_eq!(msgs[0].sender_origin, Some("https://external.com".into()));
345        assert_eq!(msgs[1].sender_tab_id, Some(1));
346    }
347
348    // ============================================================
349    // CUSTOM STATE BUILDER TESTS
350    // ============================================================
351
352    #[test]
353    fn custom_state() {
354        let state = ExtensionState::default()
355            .with_tabs(vec![
356                Tab {
357                    id: 1,
358                    url: "https://a.com".into(),
359                    title: "A".into(),
360                    active: true,
361                    index: 0,
362                },
363                Tab {
364                    id: 2,
365                    url: "https://b.com".into(),
366                    title: "B".into(),
367                    active: false,
368                    index: 1,
369                },
370            ])
371            .with_storage(HashMap::from([("key".into(), "value".into())]));
372
373        assert_eq!(state.tabs.lock().unwrap().len(), 2);
374        assert_eq!(
375            state.storage_local.lock().unwrap().get("key").unwrap(),
376            "value"
377        );
378    }
379
380    #[test]
381    fn with_tabs_empty() {
382        let state = ExtensionState::default().with_tabs(vec![]);
383        assert!(state.tabs.lock().unwrap().is_empty());
384    }
385
386    #[test]
387    fn with_tabs_single() {
388        let state = ExtensionState::default().with_tabs(vec![Tab {
389            id: 42,
390            url: "https://test.com".into(),
391            title: "Test".into(),
392            active: true,
393            index: 0,
394        }]);
395        let tabs = state.tabs.lock().unwrap();
396        assert_eq!(tabs.len(), 1);
397        assert_eq!(tabs[0].id, 42);
398    }
399
400    #[test]
401    fn with_tabs_multiple() {
402        let tabs_vec: Vec<Tab> = (0..10)
403            .map(|i| Tab {
404                id: i,
405                url: format!("https://site{}.com", i),
406                title: format!("Site {}", i),
407                active: i == 0,
408                index: i as u32,
409            })
410            .collect();
411
412        let state = ExtensionState::default().with_tabs(tabs_vec);
413        assert_eq!(state.tabs.lock().unwrap().len(), 10);
414    }
415
416    #[test]
417    fn with_cookies_empty() {
418        let state = ExtensionState::default().with_cookies(vec![]);
419        assert!(state.cookies.lock().unwrap().is_empty());
420    }
421
422    #[test]
423    fn with_cookies_single() {
424        let state = ExtensionState::default().with_cookies(vec![Cookie {
425            name: "custom".into(),
426            value: "value".into(),
427            domain: ".custom.com".into(),
428            path: "/path".into(),
429            secure: false,
430            http_only: false,
431        }]);
432        let cookies = state.cookies.lock().unwrap();
433        assert_eq!(cookies.len(), 1);
434        assert_eq!(cookies[0].name, "custom");
435    }
436
437    #[test]
438    fn with_cookies_multiple() {
439        let cookies_vec: Vec<Cookie> = (0..10)
440            .map(|i| Cookie {
441                name: format!("cookie{}", i),
442                value: format!("value{}", i),
443                domain: format!(".site{}.com", i),
444                path: "/".into(),
445                secure: i % 2 == 0,
446                http_only: i % 2 == 1,
447            })
448            .collect();
449
450        let state = ExtensionState::default().with_cookies(cookies_vec);
451        assert_eq!(state.cookies.lock().unwrap().len(), 10);
452    }
453
454    #[test]
455    fn with_storage_empty() {
456        let state = ExtensionState::default().with_storage(HashMap::new());
457        assert!(state.storage_local.lock().unwrap().is_empty());
458    }
459
460    #[test]
461    fn with_storage_single() {
462        let mut map = HashMap::new();
463        map.insert("key1".into(), "value1".into());
464        let state = ExtensionState::default().with_storage(map);
465        assert_eq!(
466            state.storage_local.lock().unwrap().get("key1"),
467            Some(&"value1".into())
468        );
469    }
470
471    #[test]
472    fn with_storage_multiple() {
473        let map: HashMap<String, String> = (0..10)
474            .map(|i| (format!("key{}", i), format!("value{}", i)))
475            .collect();
476
477        let state = ExtensionState::default().with_storage(map);
478        let storage = state.storage_local.lock().unwrap();
479        assert_eq!(storage.len(), 10);
480        assert_eq!(storage.get("key5"), Some(&"value5".into()));
481    }
482
483    #[test]
484    fn with_storage_overwrites_default() {
485        let mut map = HashMap::new();
486        map.insert("custom".into(), "data".into());
487        let state = ExtensionState::default().with_storage(map);
488        let storage = state.storage_local.lock().unwrap();
489        assert!(storage.get("custom").is_some());
490        assert_eq!(storage.len(), 1); // Only the custom data
491    }
492
493    #[test]
494    fn chained_builders() {
495        let state = ExtensionState::default()
496            .with_tabs(vec![Tab {
497                id: 1,
498                url: "https://a.com".into(),
499                title: "A".into(),
500                active: true,
501                index: 0,
502            }])
503            .with_cookies(vec![Cookie {
504                name: "c".into(),
505                value: "v".into(),
506                domain: "d".into(),
507                path: "/".into(),
508                secure: true,
509                http_only: true,
510            }])
511            .with_storage(HashMap::from([("k".into(), "v".into())]));
512
513        assert_eq!(state.tabs.lock().unwrap().len(), 1);
514        assert_eq!(state.cookies.lock().unwrap().len(), 1);
515        assert_eq!(state.storage_local.lock().unwrap().len(), 1);
516    }
517
518    // ============================================================
519    // EMPTY STATE TESTS
520    // ============================================================
521
522    #[test]
523    fn empty_state_tabs() {
524        let state = ExtensionState::default().with_tabs(vec![]);
525        assert!(state.tabs.lock().unwrap().is_empty());
526        assert_eq!(state.tabs.lock().unwrap().len(), 0);
527    }
528
529    #[test]
530    fn empty_state_cookies() {
531        let state = ExtensionState::default().with_cookies(vec![]);
532        assert!(state.cookies.lock().unwrap().is_empty());
533    }
534
535    #[test]
536    fn empty_state_storage() {
537        let state = ExtensionState::default().with_storage(HashMap::new());
538        assert!(state.storage_local.lock().unwrap().is_empty());
539    }
540
541    #[test]
542    fn empty_state_all() {
543        let state = ExtensionState::default()
544            .with_tabs(vec![])
545            .with_cookies(vec![])
546            .with_storage(HashMap::new());
547
548        assert!(state.tabs.lock().unwrap().is_empty());
549        assert!(state.cookies.lock().unwrap().is_empty());
550        assert!(state.storage_local.lock().unwrap().is_empty());
551        assert!(state.storage_sync.lock().unwrap().is_empty());
552        assert!(state.alarms.lock().unwrap().is_empty());
553        assert!(state.pending_messages.lock().unwrap().is_empty());
554    }
555
556    // ============================================================
557    // LARGE STATE TESTS
558    // ============================================================
559
560    #[test]
561    fn large_state_many_tabs() {
562        let tabs: Vec<Tab> = (0..100)
563            .map(|i| Tab {
564                id: i,
565                url: format!("https://site{}.com/page", i),
566                title: format!("Tab {} Title", i),
567                active: i == 0,
568                index: i as u32,
569            })
570            .collect();
571
572        let state = ExtensionState::default().with_tabs(tabs);
573        assert_eq!(state.tabs.lock().unwrap().len(), 100);
574    }
575
576    #[test]
577    fn large_state_many_cookies() {
578        let cookies: Vec<Cookie> = (0..1000)
579            .map(|i| Cookie {
580                name: format!("cookie_{}", i),
581                value: format!("value_{}_{}", i, "x".repeat(50)),
582                domain: format!(".domain{}.com", i % 100),
583                path: "/".into(),
584                secure: i % 3 == 0,
585                http_only: i % 5 == 0,
586            })
587            .collect();
588
589        let state = ExtensionState::default().with_cookies(cookies);
590        assert_eq!(state.cookies.lock().unwrap().len(), 1000);
591    }
592
593    #[test]
594    fn large_state_large_storage() {
595        let storage: HashMap<String, String> = (0..1000)
596            .map(|i| (format!("key_{}", i), format!("value_{}", i)))
597            .collect();
598
599        let state = ExtensionState::default().with_storage(storage);
600        assert_eq!(state.storage_local.lock().unwrap().len(), 1000);
601    }
602
603    // ============================================================
604    // CONCURRENT ACCESS TESTS (via Arc)
605    // ============================================================
606
607    #[test]
608    fn concurrent_access_tabs() {
609        let state = Arc::new(ExtensionState::default());
610        let state2 = state.clone();
611
612        // Modify from one reference
613        state.tabs.lock().unwrap().push(Tab {
614            id: 999,
615            url: "https://new.com".into(),
616            title: "New".into(),
617            active: false,
618            index: 1,
619        });
620
621        // Should be visible from other reference
622        assert_eq!(state2.tabs.lock().unwrap().len(), 2);
623    }
624
625    #[test]
626    fn concurrent_access_storage() {
627        let state = Arc::new(ExtensionState::default());
628        let state2 = state.clone();
629
630        state
631            .storage_local
632            .lock()
633            .unwrap()
634            .insert("key".into(), "value".into());
635
636        assert_eq!(
637            state2.storage_local.lock().unwrap().get("key"),
638            Some(&"value".into())
639        );
640    }
641
642    #[test]
643    fn concurrent_access_messages() {
644        let state = Arc::new(ExtensionState::default());
645        let state2 = state.clone();
646
647        state.queue_message(PendingMessage {
648            data: Value::string("test"),
649            sender_origin: None,
650            is_external: false,
651            sender_tab_id: None,
652        });
653
654        let msgs = state2.take_messages();
655        assert_eq!(msgs.len(), 1);
656    }
657
658    #[test]
659    fn state_clone_shares_data() {
660        // ExtensionState uses Arc<Mutex<...>> so clones share the same data
661        let state1 = ExtensionState::default();
662        let state2 = state1.clone();
663
664        // Modify state1
665        state1.tabs.lock().unwrap().clear();
666
667        // state2 sees the same change because they share the Arc
668        // NOTE: This is the actual behavior - clones share state via Arc
669        assert!(state2.tabs.lock().unwrap().is_empty());
670        assert!(state1.tabs.lock().unwrap().is_empty());
671    }
672
673    // ============================================================
674    // TAB STRUCT TESTS
675    // ============================================================
676
677    #[test]
678    fn tab_struct_all_fields() {
679        let tab = Tab {
680            id: 123,
681            url: "https://example.com/path".into(),
682            title: "Example Title".into(),
683            active: true,
684            index: 5,
685        };
686        assert_eq!(tab.id, 123);
687        assert_eq!(tab.url, "https://example.com/path");
688        assert_eq!(tab.title, "Example Title");
689        assert!(tab.active);
690        assert_eq!(tab.index, 5);
691    }
692
693    #[test]
694    fn tab_inactive() {
695        let tab = Tab {
696            id: 1,
697            url: "https://example.com".into(),
698            title: "Example".into(),
699            active: false,
700            index: 0,
701        };
702        assert!(!tab.active);
703    }
704
705    // ============================================================
706    // COOKIE STRUCT TESTS
707    // ============================================================
708
709    #[test]
710    fn cookie_struct_all_fields() {
711        let cookie = Cookie {
712            name: "session_id".into(),
713            value: "abc123xyz".into(),
714            domain: ".example.com".into(),
715            path: "/api".into(),
716            secure: true,
717            http_only: true,
718        };
719        assert_eq!(cookie.name, "session_id");
720        assert_eq!(cookie.value, "abc123xyz");
721        assert_eq!(cookie.domain, ".example.com");
722        assert_eq!(cookie.path, "/api");
723        assert!(cookie.secure);
724        assert!(cookie.http_only);
725    }
726
727    #[test]
728    fn cookie_non_secure() {
729        let cookie = Cookie {
730            name: "test".into(),
731            value: "value".into(),
732            domain: "example.com".into(),
733            path: "/".into(),
734            secure: false,
735            http_only: false,
736        };
737        assert!(!cookie.secure);
738        assert!(!cookie.http_only);
739    }
740
741    // ============================================================
742    // ALARM STRUCT TESTS
743    // ============================================================
744
745    #[test]
746    fn alarm_struct_all_fields() {
747        let alarm = Alarm {
748            name: "alarm1".into(),
749            scheduled_time: 1234567890.5,
750            period_in_minutes: Some(5.0),
751        };
752        assert_eq!(alarm.name, "alarm1");
753        assert_eq!(alarm.scheduled_time, 1234567890.5);
754        assert_eq!(alarm.period_in_minutes, Some(5.0));
755    }
756
757    #[test]
758    fn alarm_one_time() {
759        let alarm = Alarm {
760            name: "once".into(),
761            scheduled_time: 1234567890.0,
762            period_in_minutes: None,
763        };
764        assert!(alarm.period_in_minutes.is_none());
765    }
766
767    // ============================================================
768    // PENDING MESSAGE STRUCT TESTS
769    // ============================================================
770
771    #[test]
772    fn pending_message_all_fields() {
773        let msg = PendingMessage {
774            data: Value::json(r#"{"action": "test"}"#),
775            sender_origin: Some("https://sender.com".into()),
776            is_external: true,
777            sender_tab_id: Some(42),
778        };
779        assert_eq!(msg.data, Value::json(r#"{"action": "test"}"#));
780        assert_eq!(msg.sender_origin, Some("https://sender.com".into()));
781        assert!(msg.is_external);
782        assert_eq!(msg.sender_tab_id, Some(42));
783    }
784
785    #[test]
786    fn pending_message_minimal() {
787        let msg = PendingMessage {
788            data: Value::Null,
789            sender_origin: None,
790            is_external: false,
791            sender_tab_id: None,
792        };
793        assert_eq!(msg.data, Value::Null);
794        assert!(msg.sender_origin.is_none());
795        assert!(!msg.is_external);
796        assert!(msg.sender_tab_id.is_none());
797    }
798}