fxa_client/internal/
close_tabs.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5use std::mem;
6
7use payload_support::Fit;
8
9use super::{
10    commands::{
11        close_tabs::{self, CloseTabsPayload},
12        decrypt_command, encrypt_command, IncomingDeviceCommand, PrivateCommandKeys,
13    },
14    device::COMMAND_MAX_PAYLOAD_SIZE,
15    http_client::GetDeviceResponse,
16    scopes, telemetry, FirefoxAccount,
17};
18use crate::{warn, CloseTabsResult, Error, Result};
19
20impl FirefoxAccount {
21    pub fn close_tabs<T>(&mut self, target_device_id: &str, urls: Vec<T>) -> Result<CloseTabsResult>
22    where
23        T: Into<String>,
24    {
25        let devices = self.get_devices(false)?;
26        let target = devices
27            .iter()
28            .find(|d| d.id == target_device_id)
29            .ok_or_else(|| Error::UnknownTargetDevice(target_device_id.to_owned()))?;
30
31        let sent_telemetry = telemetry::SentCommand::for_close_tabs();
32        let mut urls_to_retry = Vec::new();
33
34        // Sort the URLs shortest to longest, so that we can at least make
35        // some forward progress, even if there's an oversized URL at the
36        // end that won't fit into a single command.
37        let mut urls: Vec<_> = urls.into_iter().map(Into::into).collect();
38        urls.sort_unstable_by_key(String::len);
39
40        while !urls.is_empty() {
41            // If we were asked to close more URLs than will fit in a
42            // single command, chunk the URLs into multiple commands,
43            // packing as many as we can into each. Do this until we've either
44            // drained and packed all the URLs, or we see an oversized URL
45            // that won't fit into a single command.
46            let chunk = match payload_support::try_fit_items(&urls, COMMAND_MAX_PAYLOAD_SIZE.get())
47            {
48                Fit::All => mem::take(&mut urls),
49                Fit::Some(count) => urls.drain(..count.get()).collect(),
50                Fit::None | Fit::Err(_) => {
51                    // Oversized URLs that won't fit into a single command, and
52                    // serialization errors, are permanent; retrying to send
53                    // these URLs won't help. But we want our consumers to keep
54                    // any pending closed URLs hidden from the user's synced
55                    // tabs list, until they're eventually sent (for temporary
56                    // errors; see below), or expire after some time
57                    // (for oversized URLs that can't ever be sent).
58                    urls_to_retry.append(&mut urls);
59                    break;
60                }
61            };
62
63            let sent_telemetry = sent_telemetry.clone_with_new_stream_id();
64            let payload = CloseTabsPayload::with_telemetry(&sent_telemetry, chunk);
65
66            let oldsync_key = self.get_scoped_key(scopes::OLD_SYNC)?;
67            let command_payload =
68                encrypt_command(oldsync_key, target, close_tabs::COMMAND_NAME, &payload)?;
69            let result = self.invoke_command(
70                close_tabs::COMMAND_NAME,
71                target,
72                &command_payload,
73                Some(close_tabs::COMMAND_TTL),
74            );
75            match result {
76                Ok(()) => {
77                    self.telemetry.record_command_sent(sent_telemetry);
78                }
79                Err(e) => {
80                    error_support::report_error!(
81                        "fxaclient-close-tabs-invoke",
82                        "Failed to send bulk Close Tabs command: {}",
83                        e
84                    );
85                    // Temporary error; if the consumer retries, we expect that
86                    // we _will_ eventually send these URLs.
87                    urls_to_retry.extend(payload.urls);
88                }
89            }
90        }
91
92        Ok(if urls_to_retry.is_empty() {
93            CloseTabsResult::Ok
94        } else {
95            CloseTabsResult::TabsNotClosed {
96                urls: urls_to_retry,
97            }
98        })
99    }
100
101    pub(crate) fn handle_close_tabs_command(
102        &mut self,
103        sender: Option<GetDeviceResponse>,
104        payload: serde_json::Value,
105        reason: telemetry::ReceivedReason,
106    ) -> Result<IncomingDeviceCommand> {
107        let close_tabs_key: PrivateCommandKeys = match self.close_tabs_key() {
108            Some(s) => PrivateCommandKeys::deserialize(s)?,
109            None => {
110                return Err(Error::IllegalState(
111                    "Cannot find Close Remote Tabs keys. Has initialize_device been called before?",
112                ));
113            }
114        };
115        match decrypt_command(payload, &close_tabs_key) {
116            Ok(payload) => {
117                let recd_telemetry = telemetry::ReceivedCommand::for_close_tabs(&payload, reason);
118                self.telemetry.record_command_received(recd_telemetry);
119                Ok(IncomingDeviceCommand::TabsClosed { sender, payload })
120            }
121            Err(e) => {
122                warn!("Could not decrypt Close Remote Tabs payload. Diagnosing then resetting the Close Tabs keys.");
123                self.clear_close_tabs_keys();
124                self.reregister_current_capabilities()?;
125                Err(e)
126            }
127        }
128    }
129
130    pub(crate) fn load_or_generate_close_tabs_keys(&mut self) -> Result<PrivateCommandKeys> {
131        if let Some(s) = self.close_tabs_key() {
132            match PrivateCommandKeys::deserialize(s) {
133                Ok(keys) => return Ok(keys),
134                Err(_) => {
135                    error_support::report_error!(
136                        "fxaclient-close-tabs-key-deserialize",
137                        "Could not deserialize Close Remote Tabs keys. Re-creating them."
138                    );
139                }
140            }
141        }
142        let keys = PrivateCommandKeys::from_random()?;
143        self.set_close_tabs_key(keys.serialize()?);
144        Ok(keys)
145    }
146
147    fn close_tabs_key(&self) -> Option<&str> {
148        self.state.get_commands_data(close_tabs::COMMAND_NAME)
149    }
150
151    fn set_close_tabs_key(&mut self, key: String) {
152        self.state.set_commands_data(close_tabs::COMMAND_NAME, key)
153    }
154
155    fn clear_close_tabs_keys(&mut self) {
156        self.state.clear_commands_data(close_tabs::COMMAND_NAME);
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    use std::{collections::HashSet, sync::Arc};
165
166    use mockall::predicate::{always, eq};
167    use nss::ensure_initialized;
168    use serde_json::json;
169
170    use crate::{
171        internal::{
172            commands::PublicCommandKeys, config::Config, http_client::MockFxAClient,
173            oauth::RefreshToken, util, CachedResponse, FirefoxAccount,
174        },
175        ScopedKey,
176    };
177
178    /// An RAII helper that overrides the maximum command payload size
179    /// for testing, and restores the original size when dropped.
180    struct OverrideCommandMaxPayloadSize(usize);
181
182    impl OverrideCommandMaxPayloadSize {
183        pub fn with_new_size(new_size: usize) -> Self {
184            Self(COMMAND_MAX_PAYLOAD_SIZE.replace(new_size))
185        }
186    }
187
188    impl Drop for OverrideCommandMaxPayloadSize {
189        fn drop(&mut self) {
190            COMMAND_MAX_PAYLOAD_SIZE.set(self.0)
191        }
192    }
193
194    fn setup() -> FirefoxAccount {
195        ensure_initialized();
196        let config = Config::stable_dev("12345678", "https://foo.bar");
197        let mut fxa = FirefoxAccount::with_config(config);
198        fxa.state.force_refresh_token(RefreshToken {
199            token: "refreshtok".to_owned(),
200            scopes: HashSet::default(),
201        });
202        fxa.state.insert_scoped_key(scopes::OLD_SYNC, ScopedKey {
203            kty: "oct".to_string(),
204            scope: "https://identity.mozilla.com/apps/oldsync".to_string(),
205            k: "kMtwpVC0ZaYFJymPza8rXK_0CgCp3KMwRStwGfBRBDtL6hXRDVJgQFaoOQ2dimw0Bko5WVv2gNTy7RX5zFYZHg".to_string(),
206            kid: "1542236016429-Ox1FbJfFfwTe5t-xq4v2hQ".to_string(),
207        });
208        fxa
209    }
210
211    // Quasi-integration tests that stub out _just_ enough of the
212    // machinery to send and respond to "close tabs" commands.
213
214    #[test]
215    fn test_close_tabs_send_one() -> Result<()> {
216        let _o = OverrideCommandMaxPayloadSize::with_new_size(2048);
217
218        let mut fxa = setup();
219        let close_tabs_keys = PrivateCommandKeys::from_random()?;
220        let devices = json!([
221            {
222                "id": "device0102",
223                "name": "Emerald",
224                "isCurrentDevice": false,
225                "location": {},
226                "availableCommands": {
227                    close_tabs::COMMAND_NAME: PublicCommandKeys::as_command_data(
228                        &close_tabs_keys.clone().into(),
229                        fxa.state.get_scoped_key(scopes::OLD_SYNC).unwrap(),
230                    )?,
231                },
232                "pushEndpointExpired": false,
233            },
234        ]);
235        fxa.devices_cache = Some(CachedResponse {
236            response: serde_json::from_value(devices)?,
237            cached_at: util::now(),
238            etag: "".into(),
239        });
240        fxa.set_close_tabs_key(close_tabs_keys.serialize()?);
241
242        let mut client = MockFxAClient::new();
243        client
244            .expect_invoke_command()
245            .once()
246            .with(
247                always(),
248                always(),
249                always(),
250                eq("device0102"),
251                always(),
252                always(),
253            )
254            .returning(|_, _, _, _, _, _| Ok(()));
255        fxa.set_client(Arc::new(client));
256
257        // Send one command.
258        assert_eq!(
259            fxa.close_tabs("device0102", vec!["https://example.com"])?,
260            CloseTabsResult::Ok
261        );
262
263        Ok(())
264    }
265
266    #[test]
267    fn test_close_tabs_send_two() -> Result<()> {
268        let _o = OverrideCommandMaxPayloadSize::with_new_size(2048);
269
270        let mut fxa = setup();
271        let close_tabs_keys = PrivateCommandKeys::from_random()?;
272        let devices = json!([
273            {
274                "id": "device0304",
275                "name": "Sapphire",
276                "isCurrentDevice": false,
277                "location": {},
278                "availableCommands": {
279                    close_tabs::COMMAND_NAME: PublicCommandKeys::as_command_data(
280                        &close_tabs_keys.clone().into(),
281                        fxa.state.get_scoped_key(scopes::OLD_SYNC).unwrap(),
282                    )?,
283                },
284                "pushEndpointExpired": false,
285            },
286        ]);
287        fxa.devices_cache = Some(CachedResponse {
288            response: serde_json::from_value(devices)?,
289            cached_at: util::now(),
290            etag: "".into(),
291        });
292        fxa.set_close_tabs_key(close_tabs_keys.serialize()?);
293
294        let mut client = MockFxAClient::new();
295        client
296            .expect_invoke_command()
297            .times(2)
298            .with(
299                always(),
300                always(),
301                always(),
302                eq("device0304"),
303                always(),
304                always(),
305            )
306            .returning(|_, _, _, _, _, _| Ok(()));
307        fxa.set_client(Arc::new(client));
308
309        // Send two commands.
310        assert_eq!(
311            fxa.close_tabs(
312                "device0304",
313                vec!["https://example.com", "https://example.org"],
314            )?,
315            CloseTabsResult::Ok
316        );
317
318        Ok(())
319    }
320
321    #[test]
322    fn test_close_tabs_all_fail() -> Result<()> {
323        let _o = OverrideCommandMaxPayloadSize::with_new_size(2048);
324
325        let mut fxa = setup();
326        let close_tabs_keys = PrivateCommandKeys::from_random()?;
327        let devices = json!([
328            {
329                "id": "device0506",
330                "name": "Ruby",
331                "isCurrentDevice": false,
332                "location": {},
333                "availableCommands": {
334                    close_tabs::COMMAND_NAME: PublicCommandKeys::as_command_data(
335                        &close_tabs_keys.clone().into(),
336                        fxa.state.get_scoped_key(scopes::OLD_SYNC).unwrap(),
337                    )?,
338                },
339                "pushEndpointExpired": false,
340            },
341        ]);
342        fxa.devices_cache = Some(CachedResponse {
343            response: serde_json::from_value(devices)?,
344            cached_at: util::now(),
345            etag: "".into(),
346        });
347        fxa.set_close_tabs_key(close_tabs_keys.serialize()?);
348
349        let mut client = MockFxAClient::new();
350        client
351            .expect_invoke_command()
352            .times(3)
353            .with(
354                always(),
355                always(),
356                always(),
357                eq("device0506"),
358                always(),
359                always(),
360            )
361            .returning(|_, _, _, _, _, _| {
362                Err(Error::RequestError(viaduct::Error::NetworkError(
363                    "Simulated error".to_owned(),
364                )))
365            });
366        fxa.set_client(Arc::new(client));
367
368        // Fail to send any commands.
369        assert_eq!(
370            fxa.close_tabs(
371                "device0506",
372                vec![
373                    "https://example.com",
374                    "https://example.org",
375                    "https://example.net",
376                ],
377            )?,
378            CloseTabsResult::TabsNotClosed {
379                urls: vec![
380                    "https://example.com".into(),
381                    "https://example.org".into(),
382                    "https://example.net".into(),
383                ]
384            }
385        );
386
387        Ok(())
388    }
389
390    #[test]
391    fn test_close_tabs_one_fails() -> Result<()> {
392        let _o = OverrideCommandMaxPayloadSize::with_new_size(2048);
393
394        let mut fxa = setup();
395        let close_tabs_keys = PrivateCommandKeys::from_random()?;
396        let devices = json!([
397            {
398                "id": "device0708",
399                "name": "Agate",
400                "isCurrentDevice": false,
401                "location": {},
402                "availableCommands": {
403                    close_tabs::COMMAND_NAME: PublicCommandKeys::as_command_data(
404                        &close_tabs_keys.clone().into(),
405                        fxa.state.get_scoped_key(scopes::OLD_SYNC).unwrap(),
406                    )?,
407                },
408                "pushEndpointExpired": false,
409            },
410        ]);
411        fxa.devices_cache = Some(CachedResponse {
412            response: serde_json::from_value(devices)?,
413            cached_at: util::now(),
414            etag: "".into(),
415        });
416        fxa.set_close_tabs_key(close_tabs_keys.serialize()?);
417
418        let mut client = MockFxAClient::new();
419        client
420            .expect_invoke_command()
421            .times(3)
422            .with(
423                always(),
424                always(),
425                always(),
426                eq("device0708"),
427                always(),
428                always(),
429            )
430            // `.returning()` boxes its closure, so we need to capture
431            // the keys by `move`.
432            .returning(move |_, _, _, _, value, _| {
433                let payload: CloseTabsPayload = decrypt_command(value.clone(), &close_tabs_keys)?;
434                if payload.urls.iter().any(|url| url == "https://example.org") {
435                    Err(Error::RequestError(viaduct::Error::NetworkError(
436                        "Simulated error".to_owned(),
437                    )))
438                } else {
439                    Ok(())
440                }
441            });
442        fxa.set_client(Arc::new(client));
443
444        // Send two commands; fail to send one.
445        assert_eq!(
446            fxa.close_tabs(
447                "device0708",
448                vec![
449                    "https://example.com",
450                    "https://example.org",
451                    "https://example.net",
452                ],
453            )?,
454            CloseTabsResult::TabsNotClosed {
455                urls: vec!["https://example.org".into()]
456            }
457        );
458
459        Ok(())
460    }
461
462    #[test]
463    fn test_close_tabs_never_sent() -> Result<()> {
464        // Lower the maximum payload size such that we can't send
465        // any commands.
466        let _p = OverrideCommandMaxPayloadSize::with_new_size(0);
467
468        let mut fxa = setup();
469        let close_tabs_keys = PrivateCommandKeys::from_random()?;
470        let devices = json!([
471            {
472                "id": "device0910",
473                "name": "Amethyst",
474                "isCurrentDevice": false,
475                "location": {},
476                "availableCommands": {
477                    close_tabs::COMMAND_NAME: PublicCommandKeys::as_command_data(
478                        &close_tabs_keys.clone().into(),
479                        fxa.state.get_scoped_key(scopes::OLD_SYNC).unwrap(),
480                    )?,
481                },
482                "pushEndpointExpired": false,
483            },
484        ]);
485        fxa.devices_cache = Some(CachedResponse {
486            response: serde_json::from_value(devices)?,
487            cached_at: util::now(),
488            etag: "".into(),
489        });
490        fxa.set_close_tabs_key(close_tabs_keys.serialize()?);
491
492        let mut client = MockFxAClient::new();
493        client.expect_invoke_command().never().with(
494            always(),
495            always(),
496            always(),
497            eq("device0910"),
498            always(),
499            always(),
500        );
501        fxa.set_client(Arc::new(client));
502
503        assert_eq!(
504            fxa.close_tabs("device0910", vec!["https://example.com"])?,
505            CloseTabsResult::TabsNotClosed {
506                urls: vec!["https://example.com".into()]
507            }
508        );
509
510        Ok(())
511    }
512
513    #[test]
514    fn test_close_tabs_two_per_command() -> Result<()> {
515        // Raise the maximum payload size to 2 URLs per command.
516        let _q = OverrideCommandMaxPayloadSize::with_new_size(2088);
517
518        let mut fxa = setup();
519        let close_tabs_keys = PrivateCommandKeys::from_random()?;
520        let devices = json!([
521            {
522                "id": "device1112",
523                "name": "Diamond",
524                "isCurrentDevice": false,
525                "location": {},
526                "availableCommands": {
527                    close_tabs::COMMAND_NAME: PublicCommandKeys::as_command_data(
528                        &close_tabs_keys.clone().into(),
529                        fxa.state.get_scoped_key(scopes::OLD_SYNC).unwrap(),
530                    )?,
531                },
532                "pushEndpointExpired": false,
533            },
534        ]);
535        fxa.devices_cache = Some(CachedResponse {
536            response: serde_json::from_value(devices)?,
537            cached_at: util::now(),
538            etag: "".into(),
539        });
540        fxa.set_close_tabs_key(close_tabs_keys.serialize()?);
541
542        let mut client = MockFxAClient::new();
543        client
544            .expect_invoke_command()
545            .times(2)
546            .with(
547                always(),
548                always(),
549                always(),
550                eq("device1112"),
551                always(),
552                always(),
553            )
554            .returning(|_, _, _, _, _, _| Ok(()));
555        fxa.set_client(Arc::new(client));
556
557        assert_eq!(
558            fxa.close_tabs(
559                "device1112",
560                vec![
561                    "https://example.com/abcdefghi",
562                    "https://example.org/jklmnopqr",
563                    "https://example.net/stuvwxyza",
564                    "https://example.edu/bcdefghij",
565                ],
566            )?,
567            CloseTabsResult::Ok
568        );
569
570        Ok(())
571    }
572}