1 # Copyright 2013 The Chromium Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file.
5 """An implementation of the server side of the Chromium sync protocol.
7 The details of the protocol are described mostly by comments in the protocol
8 buffer definition at chrome/browser/sync/protocol/sync.proto.
14 import google.protobuf.text_format
26 import app_list_specifics_pb2
27 import app_notification_specifics_pb2
28 import app_setting_specifics_pb2
29 import app_specifics_pb2
30 import article_specifics_pb2
31 import autofill_specifics_pb2
32 import bookmark_specifics_pb2
33 import client_commands_pb2
34 import dictionary_specifics_pb2
35 import get_updates_caller_info_pb2
36 import extension_setting_specifics_pb2
37 import extension_specifics_pb2
38 import favicon_image_specifics_pb2
39 import favicon_tracking_specifics_pb2
40 import history_delete_directive_specifics_pb2
41 import managed_user_setting_specifics_pb2
42 import managed_user_specifics_pb2
43 import managed_user_shared_setting_specifics_pb2
44 import nigori_specifics_pb2
45 import password_specifics_pb2
46 import preference_specifics_pb2
47 import priority_preference_specifics_pb2
48 import search_engine_specifics_pb2
49 import session_specifics_pb2
52 import synced_notification_app_info_specifics_pb2
53 import synced_notification_data_pb2
54 import synced_notification_render_pb2
55 import synced_notification_specifics_pb2
56 import theme_specifics_pb2
57 import typed_url_specifics_pb2
59 # An enumeration of the various kinds of data that can be synced.
60 # Over the wire, this enumeration is not used: a sync object's type is
61 # inferred by which EntitySpecifics field it has. But in the context
62 # of a program, it is useful to have an enumeration.
64 TOP_LEVEL, # The type of the 'Google Chrome' folder.
77 HISTORY_DELETE_DIRECTIVE,
79 MANAGED_USER_SHARED_SETTING,
88 SYNCED_NOTIFICATION_APP_INFO,
93 FAVICON_TRACKING) = range(30)
95 # An enumeration on the frequency at which the server should send errors
96 # to the client. This would be specified by the url that triggers the error.
97 # Note: This enum should be kept in the same order as the enum in sync_test.h.
98 SYNC_ERROR_FREQUENCY = (
100 ERROR_FREQUENCY_ALWAYS,
101 ERROR_FREQUENCY_TWO_THIRDS) = range(3)
103 # Well-known server tag of the top level 'Google Chrome' folder.
104 TOP_LEVEL_FOLDER_TAG = 'google_chrome'
106 # Given a sync type from ALL_TYPES, find the FieldDescriptor corresponding
107 # to that datatype. Note that TOP_LEVEL has no such token.
108 SYNC_TYPE_FIELDS = sync_pb2.EntitySpecifics.DESCRIPTOR.fields_by_name
109 SYNC_TYPE_TO_DESCRIPTOR = {
110 APP_LIST: SYNC_TYPE_FIELDS['app_list'],
111 APP_NOTIFICATION: SYNC_TYPE_FIELDS['app_notification'],
112 APP_SETTINGS: SYNC_TYPE_FIELDS['app_setting'],
113 APPS: SYNC_TYPE_FIELDS['app'],
114 ARTICLE: SYNC_TYPE_FIELDS['article'],
115 AUTOFILL: SYNC_TYPE_FIELDS['autofill'],
116 AUTOFILL_PROFILE: SYNC_TYPE_FIELDS['autofill_profile'],
117 BOOKMARK: SYNC_TYPE_FIELDS['bookmark'],
118 DEVICE_INFO: SYNC_TYPE_FIELDS['device_info'],
119 DICTIONARY: SYNC_TYPE_FIELDS['dictionary'],
120 EXPERIMENTS: SYNC_TYPE_FIELDS['experiments'],
121 EXTENSION_SETTINGS: SYNC_TYPE_FIELDS['extension_setting'],
122 EXTENSIONS: SYNC_TYPE_FIELDS['extension'],
123 FAVICON_IMAGES: SYNC_TYPE_FIELDS['favicon_image'],
124 FAVICON_TRACKING: SYNC_TYPE_FIELDS['favicon_tracking'],
125 HISTORY_DELETE_DIRECTIVE: SYNC_TYPE_FIELDS['history_delete_directive'],
126 MANAGED_USER_SHARED_SETTING:
127 SYNC_TYPE_FIELDS['managed_user_shared_setting'],
128 MANAGED_USER_SETTING: SYNC_TYPE_FIELDS['managed_user_setting'],
129 MANAGED_USER: SYNC_TYPE_FIELDS['managed_user'],
130 NIGORI: SYNC_TYPE_FIELDS['nigori'],
131 PASSWORD: SYNC_TYPE_FIELDS['password'],
132 PREFERENCE: SYNC_TYPE_FIELDS['preference'],
133 PRIORITY_PREFERENCE: SYNC_TYPE_FIELDS['priority_preference'],
134 SEARCH_ENGINE: SYNC_TYPE_FIELDS['search_engine'],
135 SESSION: SYNC_TYPE_FIELDS['session'],
136 SYNCED_NOTIFICATION: SYNC_TYPE_FIELDS["synced_notification"],
137 SYNCED_NOTIFICATION_APP_INFO:
138 SYNC_TYPE_FIELDS["synced_notification_app_info"],
139 THEME: SYNC_TYPE_FIELDS['theme'],
140 TYPED_URL: SYNC_TYPE_FIELDS['typed_url'],
143 # The parent ID used to indicate a top-level node.
146 # Unix time epoch +1 day in struct_time format. The tuple corresponds to
147 # UTC Thursday Jan 2 1970, 00:00:00, non-dst.
148 # We have to add one day after start of epoch, since in timezones with positive
149 # UTC offset time.mktime throws an OverflowError,
150 # rather then returning negative number.
151 FIRST_DAY_UNIX_TIME_EPOCH = (1970, 1, 2, 0, 0, 0, 4, 2, 0)
152 ONE_DAY_SECONDS = 60 * 60 * 24
154 # The number of characters in the server-generated encryption key.
155 KEYSTORE_KEY_LENGTH = 16
157 # The hashed client tags for some experiment nodes.
158 KEYSTORE_ENCRYPTION_EXPERIMENT_TAG = "pis8ZRzh98/MKLtVEio2mr42LQA="
159 PRE_COMMIT_GU_AVOIDANCE_EXPERIMENT_TAG = "Z1xgeh3QUBa50vdEPd8C/4c7jfE="
161 class Error(Exception):
162 """Error class for this module."""
165 class ProtobufDataTypeFieldNotUnique(Error):
166 """An entry should not have more than one data type present."""
169 class DataTypeIdNotRecognized(Error):
170 """The requested data type is not recognized."""
173 class MigrationDoneError(Error):
174 """A server-side migration occurred; clients must re-sync some datatypes.
177 datatypes: a list of the datatypes (python enum) needing migration.
180 def __init__(self, datatypes):
181 self.datatypes = datatypes
184 class StoreBirthdayError(Error):
185 """The client sent a birthday that doesn't correspond to this server."""
188 class TransientError(Error):
189 """The client would be sent a transient error."""
192 class SyncInducedError(Error):
193 """The client would be sent an error."""
196 class InducedErrorFrequencyNotDefined(Error):
197 """The error frequency defined is not handled."""
200 class ClientNotConnectedError(Error):
201 """The client is not connected to the server."""
204 def GetEntryType(entry):
205 """Extract the sync type from a SyncEntry.
208 entry: A SyncEntity protobuf object whose type to determine.
210 An enum value from ALL_TYPES if the entry's type can be determined, or None
211 if the type cannot be determined.
213 ProtobufDataTypeFieldNotUnique: More than one type was indicated by
216 if entry.server_defined_unique_tag == TOP_LEVEL_FOLDER_TAG:
218 entry_types = GetEntryTypesFromSpecifics(entry.specifics)
222 # If there is more than one, either there's a bug, or else the caller
223 # should use GetEntryTypes.
224 if len(entry_types) > 1:
225 raise ProtobufDataTypeFieldNotUnique
226 return entry_types[0]
229 def GetEntryTypesFromSpecifics(specifics):
230 """Determine the sync types indicated by an EntitySpecifics's field(s).
232 If the specifics have more than one recognized data type field (as commonly
233 happens with the requested_types field of GetUpdatesMessage), all types
234 will be returned. Callers must handle the possibility of the returned
235 value having more than one item.
238 specifics: A EntitySpecifics protobuf message whose extensions to
241 A list of the sync types (values from ALL_TYPES) associated with each
242 recognized extension of the specifics message.
244 return [data_type for data_type, field_descriptor
245 in SYNC_TYPE_TO_DESCRIPTOR.iteritems()
246 if specifics.HasField(field_descriptor.name)]
249 def SyncTypeToProtocolDataTypeId(data_type):
250 """Convert from a sync type (python enum) to the protocol's data type id."""
251 return SYNC_TYPE_TO_DESCRIPTOR[data_type].number
254 def ProtocolDataTypeIdToSyncType(protocol_data_type_id):
255 """Convert from the protocol's data type id to a sync type (python enum)."""
256 for data_type, field_descriptor in SYNC_TYPE_TO_DESCRIPTOR.iteritems():
257 if field_descriptor.number == protocol_data_type_id:
259 raise DataTypeIdNotRecognized
262 def DataTypeStringToSyncTypeLoose(data_type_string):
263 """Converts a human-readable string to a sync type (python enum).
265 Capitalization and pluralization don't matter; this function is appropriate
266 for values that might have been typed by a human being; e.g., command-line
267 flags or query parameters.
269 if data_type_string.isdigit():
270 return ProtocolDataTypeIdToSyncType(int(data_type_string))
271 name = data_type_string.lower().rstrip('s')
272 for data_type, field_descriptor in SYNC_TYPE_TO_DESCRIPTOR.iteritems():
273 if field_descriptor.name.lower().rstrip('s') == name:
275 raise DataTypeIdNotRecognized
278 def MakeNewKeystoreKey():
279 """Returns a new random keystore key."""
280 return ''.join(random.choice(string.ascii_uppercase + string.digits)
281 for x in xrange(KEYSTORE_KEY_LENGTH))
284 def SyncTypeToString(data_type):
285 """Formats a sync type enum (from ALL_TYPES) to a human-readable string."""
286 return SYNC_TYPE_TO_DESCRIPTOR[data_type].name
289 def CallerInfoToString(caller_info_source):
290 """Formats a GetUpdatesSource enum value to a readable string."""
291 return get_updates_caller_info_pb2.GetUpdatesCallerInfo \
292 .DESCRIPTOR.enum_types_by_name['GetUpdatesSource'] \
293 .values_by_number[caller_info_source].name
296 def ShortDatatypeListSummary(data_types):
297 """Formats compactly a list of sync types (python enums) for human eyes.
299 This function is intended for use by logging. If the list of datatypes
300 contains almost all of the values, the return value will be expressed
301 in terms of the datatypes that aren't set.
303 included = set(data_types) - set([TOP_LEVEL])
306 excluded = set(ALL_TYPES) - included - set([TOP_LEVEL])
309 simple_text = '+'.join(sorted([SyncTypeToString(x) for x in included]))
310 all_but_text = 'all except %s' % (
311 '+'.join(sorted([SyncTypeToString(x) for x in excluded])))
312 if len(included) < len(excluded) or len(simple_text) <= len(all_but_text):
318 def GetDefaultEntitySpecifics(data_type):
319 """Get an EntitySpecifics having a sync type's default field value."""
320 specifics = sync_pb2.EntitySpecifics()
321 if data_type in SYNC_TYPE_TO_DESCRIPTOR:
322 descriptor = SYNC_TYPE_TO_DESCRIPTOR[data_type]
323 getattr(specifics, descriptor.name).SetInParent()
327 class PermanentItem(object):
328 """A specification of one server-created permanent item.
331 tag: A known-to-the-client value that uniquely identifies a server-created
333 name: The human-readable display name for this item.
334 parent_tag: The tag of the permanent item's parent. If ROOT_ID, indicates
335 a top-level item. Otherwise, this must be the tag value of some other
336 server-created permanent item.
337 sync_type: A value from ALL_TYPES, giving the datatype of this permanent
338 item. This controls which types of client GetUpdates requests will
339 cause the permanent item to be created and returned.
340 create_by_default: Whether the permanent item is created at startup or not.
341 This value is set to True in the default case. Non-default permanent items
342 are those that are created only when a client explicitly tells the server
346 def __init__(self, tag, name, parent_tag, sync_type, create_by_default=True):
349 self.parent_tag = parent_tag
350 self.sync_type = sync_type
351 self.create_by_default = create_by_default
354 class MigrationHistory(object):
355 """A record of the migration events associated with an account.
357 Each migration event invalidates one or more datatypes on all clients
358 that had synced the datatype before the event. Such clients will continue
359 to receive MigrationDone errors until they throw away their progress and
360 re-sync that datatype from the beginning.
363 self._migrations = {}
364 for datatype in ALL_TYPES:
365 self._migrations[datatype] = [1]
366 self._next_migration_version = 2
368 def GetLatestVersion(self, datatype):
369 return self._migrations[datatype][-1]
371 def CheckAllCurrent(self, versions_map):
372 """Raises an error if any the provided versions are out of date.
374 This function intentionally returns migrations in the order that they were
375 triggered. Doing it this way allows the client to queue up two migrations
376 in a row, so the second one is received while responding to the first.
379 version_map: a map whose keys are datatypes and whose values are versions.
382 MigrationDoneError: if a mismatch is found.
385 for datatype, client_migration in versions_map.iteritems():
386 for server_migration in self._migrations[datatype]:
387 if client_migration < server_migration:
388 problems.setdefault(server_migration, []).append(datatype)
390 raise MigrationDoneError(problems[min(problems.keys())])
392 def Bump(self, datatypes):
393 """Add a record of a migration, to cause errors on future requests."""
394 for idx, datatype in enumerate(datatypes):
395 self._migrations[datatype].append(self._next_migration_version)
396 self._next_migration_version += 1
399 class UpdateSieve(object):
400 """A filter to remove items the client has already seen."""
401 def __init__(self, request, migration_history=None):
402 self._original_request = request
404 self._migration_history = migration_history or MigrationHistory()
405 self._migration_versions_to_check = {}
406 if request.from_progress_marker:
407 for marker in request.from_progress_marker:
408 data_type = ProtocolDataTypeIdToSyncType(marker.data_type_id)
409 if marker.HasField('timestamp_token_for_migration'):
410 timestamp = marker.timestamp_token_for_migration
412 self._migration_versions_to_check[data_type] = 1
414 (timestamp, version) = pickle.loads(marker.token)
415 self._migration_versions_to_check[data_type] = version
416 elif marker.HasField('token'):
419 raise ValueError('No timestamp information in progress marker.')
420 data_type = ProtocolDataTypeIdToSyncType(marker.data_type_id)
421 self._state[data_type] = timestamp
422 elif request.HasField('from_timestamp'):
423 for data_type in GetEntryTypesFromSpecifics(request.requested_types):
424 self._state[data_type] = request.from_timestamp
425 self._migration_versions_to_check[data_type] = 1
427 self._state[TOP_LEVEL] = min(self._state.itervalues())
429 def SummarizeRequest(self):
431 for data_type, timestamp in self._state.iteritems():
432 if data_type == TOP_LEVEL:
434 timestamps.setdefault(timestamp, []).append(data_type)
435 return ', '.join('<%s>@%d' % (ShortDatatypeListSummary(types), stamp)
436 for stamp, types in sorted(timestamps.iteritems()))
438 def CheckMigrationState(self):
439 self._migration_history.CheckAllCurrent(self._migration_versions_to_check)
441 def ClientWantsItem(self, item):
442 """Return true if the client hasn't already seen an item."""
443 return self._state.get(GetEntryType(item), sys.maxint) < item.version
445 def HasAnyTimestamp(self):
446 """Return true if at least one datatype was requested."""
447 return bool(self._state)
449 def GetMinTimestamp(self):
450 """Return true the smallest timestamp requested across all datatypes."""
451 return min(self._state.itervalues())
453 def GetFirstTimeTypes(self):
454 """Return a list of datatypes requesting updates from timestamp zero."""
455 return [datatype for datatype, timestamp in self._state.iteritems()
458 def GetCreateMobileBookmarks(self):
459 """Return true if the client has requested to create the 'Mobile Bookmarks'
462 return (self._original_request.HasField('create_mobile_bookmarks_folder')
463 and self._original_request.create_mobile_bookmarks_folder)
465 def SaveProgress(self, new_timestamp, get_updates_response):
466 """Write the new_timestamp or new_progress_marker fields to a response."""
467 if self._original_request.from_progress_marker:
468 for data_type, old_timestamp in self._state.iteritems():
469 if data_type == TOP_LEVEL:
471 new_marker = sync_pb2.DataTypeProgressMarker()
472 new_marker.data_type_id = SyncTypeToProtocolDataTypeId(data_type)
473 final_stamp = max(old_timestamp, new_timestamp)
474 final_migration = self._migration_history.GetLatestVersion(data_type)
475 new_marker.token = pickle.dumps((final_stamp, final_migration))
476 get_updates_response.new_progress_marker.add().MergeFrom(new_marker)
477 elif self._original_request.HasField('from_timestamp'):
478 if self._original_request.from_timestamp < new_timestamp:
479 get_updates_response.new_timestamp = new_timestamp
482 class SyncDataModel(object):
483 """Models the account state of one sync user."""
486 # Specify all the permanent items that a model might need.
487 _PERMANENT_ITEM_SPECS = [
488 PermanentItem('google_chrome_apps', name='Apps',
489 parent_tag=ROOT_ID, sync_type=APPS),
490 PermanentItem('google_chrome_app_list', name='App List',
491 parent_tag=ROOT_ID, sync_type=APP_LIST),
492 PermanentItem('google_chrome_app_notifications', name='App Notifications',
493 parent_tag=ROOT_ID, sync_type=APP_NOTIFICATION),
494 PermanentItem('google_chrome_app_settings',
496 parent_tag=ROOT_ID, sync_type=APP_SETTINGS),
497 PermanentItem('google_chrome_bookmarks', name='Bookmarks',
498 parent_tag=ROOT_ID, sync_type=BOOKMARK),
499 PermanentItem('bookmark_bar', name='Bookmark Bar',
500 parent_tag='google_chrome_bookmarks', sync_type=BOOKMARK),
501 PermanentItem('other_bookmarks', name='Other Bookmarks',
502 parent_tag='google_chrome_bookmarks', sync_type=BOOKMARK),
503 PermanentItem('synced_bookmarks', name='Synced Bookmarks',
504 parent_tag='google_chrome_bookmarks', sync_type=BOOKMARK,
505 create_by_default=False),
506 PermanentItem('google_chrome_autofill', name='Autofill',
507 parent_tag=ROOT_ID, sync_type=AUTOFILL),
508 PermanentItem('google_chrome_autofill_profiles', name='Autofill Profiles',
509 parent_tag=ROOT_ID, sync_type=AUTOFILL_PROFILE),
510 PermanentItem('google_chrome_device_info', name='Device Info',
511 parent_tag=ROOT_ID, sync_type=DEVICE_INFO),
512 PermanentItem('google_chrome_experiments', name='Experiments',
513 parent_tag=ROOT_ID, sync_type=EXPERIMENTS),
514 PermanentItem('google_chrome_extension_settings',
515 name='Extension Settings',
516 parent_tag=ROOT_ID, sync_type=EXTENSION_SETTINGS),
517 PermanentItem('google_chrome_extensions', name='Extensions',
518 parent_tag=ROOT_ID, sync_type=EXTENSIONS),
519 PermanentItem('google_chrome_history_delete_directives',
520 name='History Delete Directives',
522 sync_type=HISTORY_DELETE_DIRECTIVE),
523 PermanentItem('google_chrome_favicon_images',
524 name='Favicon Images',
526 sync_type=FAVICON_IMAGES),
527 PermanentItem('google_chrome_favicon_tracking',
528 name='Favicon Tracking',
530 sync_type=FAVICON_TRACKING),
531 PermanentItem('google_chrome_managed_user_settings',
532 name='Managed User Settings',
533 parent_tag=ROOT_ID, sync_type=MANAGED_USER_SETTING),
534 PermanentItem('google_chrome_managed_users',
535 name='Managed Users',
536 parent_tag=ROOT_ID, sync_type=MANAGED_USER),
537 PermanentItem('google_chrome_managed_user_shared_settings',
538 name='Managed User Shared Settings',
539 parent_tag=ROOT_ID, sync_type=MANAGED_USER_SHARED_SETTING),
540 PermanentItem('google_chrome_nigori', name='Nigori',
541 parent_tag=ROOT_ID, sync_type=NIGORI),
542 PermanentItem('google_chrome_passwords', name='Passwords',
543 parent_tag=ROOT_ID, sync_type=PASSWORD),
544 PermanentItem('google_chrome_preferences', name='Preferences',
545 parent_tag=ROOT_ID, sync_type=PREFERENCE),
546 PermanentItem('google_chrome_priority_preferences',
547 name='Priority Preferences',
548 parent_tag=ROOT_ID, sync_type=PRIORITY_PREFERENCE),
549 PermanentItem('google_chrome_synced_notifications',
550 name='Synced Notifications',
551 parent_tag=ROOT_ID, sync_type=SYNCED_NOTIFICATION),
552 PermanentItem('google_chrome_synced_notification_app_info',
553 name='Synced Notification App Info',
554 parent_tag=ROOT_ID, sync_type=SYNCED_NOTIFICATION_APP_INFO),
555 PermanentItem('google_chrome_search_engines', name='Search Engines',
556 parent_tag=ROOT_ID, sync_type=SEARCH_ENGINE),
557 PermanentItem('google_chrome_sessions', name='Sessions',
558 parent_tag=ROOT_ID, sync_type=SESSION),
559 PermanentItem('google_chrome_themes', name='Themes',
560 parent_tag=ROOT_ID, sync_type=THEME),
561 PermanentItem('google_chrome_typed_urls', name='Typed URLs',
562 parent_tag=ROOT_ID, sync_type=TYPED_URL),
563 PermanentItem('google_chrome_dictionary', name='Dictionary',
564 parent_tag=ROOT_ID, sync_type=DICTIONARY),
565 PermanentItem('google_chrome_articles', name='Articles',
566 parent_tag=ROOT_ID, sync_type=ARTICLE),
570 # Monotonically increasing version number. The next object change will
571 # take on this value + 1.
574 # The definitive copy of this client's items: a map from ID string to a
575 # SyncEntity protocol buffer.
578 self.ResetStoreBirthday()
579 self.migration_history = MigrationHistory()
580 self.induced_error = sync_pb2.ClientToServerResponse.Error()
581 self.induced_error_frequency = 0
582 self.sync_count_before_errors = 0
583 self.acknowledge_managed_users = False
584 self._keys = [MakeNewKeystoreKey()]
586 def _SaveEntry(self, entry):
587 """Insert or update an entry in the change log, and give it a new version.
589 The ID fields of this entry are assumed to be valid server IDs. This
590 entry will be updated with a new version number and sync_timestamp.
593 entry: The entry to be added or updated.
596 # Maintain a global (rather than per-item) sequence number and use it
597 # both as the per-entry version as well as the update-progress timestamp.
598 # This simulates the behavior of the original server implementation.
599 entry.version = self._version
600 entry.sync_timestamp = self._version
602 # Preserve the originator info, which the client is not required to send
604 base_entry = self._entries.get(entry.id_string)
606 entry.originator_cache_guid = base_entry.originator_cache_guid
607 entry.originator_client_item_id = base_entry.originator_client_item_id
609 self._entries[entry.id_string] = copy.deepcopy(entry)
611 def _ServerTagToId(self, tag):
612 """Determine the server ID from a server-unique tag.
614 The resulting value is guaranteed not to collide with the other ID
618 tag: The unique, known-to-the-client tag of a server-generated item.
620 The string value of the computed server ID.
622 if not tag or tag == ROOT_ID:
624 spec = [x for x in self._PERMANENT_ITEM_SPECS if x.tag == tag][0]
625 return self._MakeCurrentId(spec.sync_type, '<server tag>%s' % tag)
627 def _ClientTagToId(self, datatype, tag):
628 """Determine the server ID from a client-unique tag.
630 The resulting value is guaranteed not to collide with the other ID
634 datatype: The sync type (python enum) of the identified object.
635 tag: The unique, opaque-to-the-server tag of a client-tagged item.
637 The string value of the computed server ID.
639 return self._MakeCurrentId(datatype, '<client tag>%s' % tag)
641 def _ClientIdToId(self, datatype, client_guid, client_item_id):
642 """Compute a unique server ID from a client-local ID tag.
644 The resulting value is guaranteed not to collide with the other ID
648 datatype: The sync type (python enum) of the identified object.
649 client_guid: A globally unique ID that identifies the client which
651 client_item_id: An ID that uniquely identifies this item on the client
654 The string value of the computed server ID.
656 # Using the client ID info is not required here (we could instead generate
657 # a random ID), but it's useful for debugging.
658 return self._MakeCurrentId(datatype,
659 '<server ID originally>%s/%s' % (client_guid, client_item_id))
661 def _MakeCurrentId(self, datatype, inner_id):
662 return '%d^%d^%s' % (datatype,
663 self.migration_history.GetLatestVersion(datatype),
666 def _ExtractIdInfo(self, id_string):
667 if not id_string or id_string == ROOT_ID:
669 datatype_string, separator, remainder = id_string.partition('^')
670 migration_version_string, separator, inner_id = remainder.partition('^')
671 return (int(datatype_string), int(migration_version_string), inner_id)
673 def _WritePosition(self, entry, parent_id):
674 """Ensure the entry has an absolute, numeric position and parent_id.
676 Historically, clients would specify positions using the predecessor-based
677 references in the insert_after_item_id field; starting July 2011, this
678 was changed and Chrome now sends up the absolute position. The server
679 must store a position_in_parent value and must not maintain
680 insert_after_item_id.
681 Starting in Jan 2013, the client will also send up a unique_position field
682 which should be saved and returned on subsequent GetUpdates.
685 entry: The entry for which to write a position. Its ID field are
686 assumed to be server IDs. This entry will have its parent_id_string,
687 position_in_parent and unique_position fields updated; its
688 insert_after_item_id field will be cleared.
689 parent_id: The ID of the entry intended as the new parent.
692 entry.parent_id_string = parent_id
693 if not entry.HasField('position_in_parent'):
694 entry.position_in_parent = 1337 # A debuggable, distinctive default.
695 entry.ClearField('insert_after_item_id')
697 def _ItemExists(self, id_string):
698 """Determine whether an item exists in the changelog."""
699 return id_string in self._entries
701 def _CreatePermanentItem(self, spec):
702 """Create one permanent item from its spec, if it doesn't exist.
704 The resulting item is added to the changelog.
707 spec: A PermanentItem object holding the properties of the item to create.
709 id_string = self._ServerTagToId(spec.tag)
710 if self._ItemExists(id_string):
712 print 'Creating permanent item: %s' % spec.name
713 entry = sync_pb2.SyncEntity()
714 entry.id_string = id_string
715 entry.non_unique_name = spec.name
716 entry.name = spec.name
717 entry.server_defined_unique_tag = spec.tag
719 entry.deleted = False
720 entry.specifics.CopyFrom(GetDefaultEntitySpecifics(spec.sync_type))
721 self._WritePosition(entry, self._ServerTagToId(spec.parent_tag))
722 self._SaveEntry(entry)
724 def _CreateDefaultPermanentItems(self, requested_types):
725 """Ensure creation of all default permanent items for a given set of types.
728 requested_types: A list of sync data types from ALL_TYPES.
729 All default permanent items of only these types will be created.
731 for spec in self._PERMANENT_ITEM_SPECS:
732 if spec.sync_type in requested_types and spec.create_by_default:
733 self._CreatePermanentItem(spec)
735 def ResetStoreBirthday(self):
736 """Resets the store birthday to a random value."""
737 # TODO(nick): uuid.uuid1() is better, but python 2.5 only.
738 self.store_birthday = '%0.30f' % random.random()
740 def StoreBirthday(self):
741 """Gets the store birthday."""
742 return self.store_birthday
744 def GetChanges(self, sieve):
745 """Get entries which have changed, oldest first.
747 The returned entries are limited to being _BATCH_SIZE many. The entries
748 are returned in strict version order.
751 sieve: An update sieve to use to filter out updates the client
754 A tuple of (version, entries, changes_remaining). Version is a new
755 timestamp value, which should be used as the starting point for the
756 next query. Entries is the batch of entries meeting the current
757 timestamp query. Changes_remaining indicates the number of changes
758 left on the server after this batch.
760 if not sieve.HasAnyTimestamp():
762 min_timestamp = sieve.GetMinTimestamp()
763 first_time_types = sieve.GetFirstTimeTypes()
764 self._CreateDefaultPermanentItems(first_time_types)
765 # Mobile bookmark folder is not created by default, create it only when
766 # client requested it.
767 if (sieve.GetCreateMobileBookmarks() and
768 first_time_types.count(BOOKMARK) > 0):
769 self.TriggerCreateSyncedBookmarks()
771 self.TriggerAcknowledgeManagedUsers()
773 change_log = sorted(self._entries.values(),
774 key=operator.attrgetter('version'))
775 new_changes = [x for x in change_log if x.version > min_timestamp]
776 # Pick batch_size new changes, and then filter them. This matches
777 # the RPC behavior of the production sync server.
778 batch = new_changes[:self._BATCH_SIZE]
780 # Client is up to date.
781 return (min_timestamp, [], 0)
783 # Restrict batch to requested types. Tombstones are untyped
784 # and will always get included.
785 filtered = [copy.deepcopy(item) for item in batch
786 if item.deleted or sieve.ClientWantsItem(item)]
788 # The new client timestamp is the timestamp of the last item in the
789 # batch, even if that item was filtered out.
790 return (batch[-1].version, filtered, len(new_changes) - len(batch))
792 def GetKeystoreKeys(self):
793 """Returns the encryption keys for this account."""
794 print "Returning encryption keys: %s" % self._keys
797 def _CopyOverImmutableFields(self, entry):
798 """Preserve immutable fields by copying pre-commit state.
801 entry: A sync entity from the client.
803 if entry.id_string in self._entries:
804 if self._entries[entry.id_string].HasField(
805 'server_defined_unique_tag'):
806 entry.server_defined_unique_tag = (
807 self._entries[entry.id_string].server_defined_unique_tag)
809 def _CheckVersionForCommit(self, entry):
810 """Perform an optimistic concurrency check on the version number.
812 Clients are only allowed to commit if they report having seen the most
813 recent version of an object.
816 entry: A sync entity from the client. It is assumed that ID fields
817 have been converted to server IDs.
819 A boolean value indicating whether the client's version matches the
820 newest server version for the given entry.
822 if entry.id_string in self._entries:
823 # Allow edits/deletes if the version matches, and any undeletion.
824 return (self._entries[entry.id_string].version == entry.version or
825 self._entries[entry.id_string].deleted)
827 # Allow unknown ID only if the client thinks it's new too.
828 return entry.version == 0
830 def _CheckParentIdForCommit(self, entry):
831 """Check that the parent ID referenced in a SyncEntity actually exists.
834 entry: A sync entity from the client. It is assumed that ID fields
835 have been converted to server IDs.
837 A boolean value indicating whether the entity's parent ID is an object
838 that actually exists (and is not deleted) in the current account state.
840 if entry.parent_id_string == ROOT_ID:
841 # This is generally allowed.
843 if entry.parent_id_string not in self._entries:
844 print 'Warning: Client sent unknown ID. Should never happen.'
846 if entry.parent_id_string == entry.id_string:
847 print 'Warning: Client sent circular reference. Should never happen.'
849 if self._entries[entry.parent_id_string].deleted:
850 # This can happen in a race condition between two clients.
852 if not self._entries[entry.parent_id_string].folder:
853 print 'Warning: Client sent non-folder parent. Should never happen.'
857 def _RewriteIdsAsServerIds(self, entry, cache_guid, commit_session):
858 """Convert ID fields in a client sync entry to server IDs.
860 A commit batch sent by a client may contain new items for which the
861 server has not generated IDs yet. And within a commit batch, later
862 items are allowed to refer to earlier items. This method will
863 generate server IDs for new items, as well as rewrite references
864 to items whose server IDs were generated earlier in the batch.
867 entry: The client sync entry to modify.
868 cache_guid: The globally unique ID of the client that sent this
870 commit_session: A dictionary mapping the original IDs to the new server
871 IDs, for any items committed earlier in the batch.
873 if entry.version == 0:
874 data_type = GetEntryType(entry)
875 if entry.HasField('client_defined_unique_tag'):
876 # When present, this should determine the item's ID.
877 new_id = self._ClientTagToId(data_type, entry.client_defined_unique_tag)
879 new_id = self._ClientIdToId(data_type, cache_guid, entry.id_string)
880 entry.originator_cache_guid = cache_guid
881 entry.originator_client_item_id = entry.id_string
882 commit_session[entry.id_string] = new_id # Remember the remapping.
883 entry.id_string = new_id
884 if entry.parent_id_string in commit_session:
885 entry.parent_id_string = commit_session[entry.parent_id_string]
886 if entry.insert_after_item_id in commit_session:
887 entry.insert_after_item_id = commit_session[entry.insert_after_item_id]
889 def ValidateCommitEntries(self, entries):
890 """Raise an exception if a commit batch contains any global errors.
893 entries: an iterable containing commit-form SyncEntity protocol buffers.
896 MigrationDoneError: if any of the entries reference a recently-migrated
899 server_ids_in_commit = set()
900 local_ids_in_commit = set()
901 for entry in entries:
903 server_ids_in_commit.add(entry.id_string)
905 local_ids_in_commit.add(entry.id_string)
906 if entry.HasField('parent_id_string'):
907 if entry.parent_id_string not in local_ids_in_commit:
908 server_ids_in_commit.add(entry.parent_id_string)
910 versions_present = {}
911 for server_id in server_ids_in_commit:
912 parsed = self._ExtractIdInfo(server_id)
914 datatype, version, _ = parsed
915 versions_present.setdefault(datatype, []).append(version)
917 self.migration_history.CheckAllCurrent(
918 dict((k, min(v)) for k, v in versions_present.iteritems()))
920 def CommitEntry(self, entry, cache_guid, commit_session):
921 """Attempt to commit one entry to the user's account.
924 entry: A SyncEntity protobuf representing desired object changes.
925 cache_guid: A string value uniquely identifying the client; this
926 is used for ID generation and will determine the originator_cache_guid
928 commit_session: A dictionary mapping client IDs to server IDs for any
929 objects committed earlier this session. If the entry gets a new ID
930 during commit, the change will be recorded here.
932 A SyncEntity reflecting the post-commit value of the entry, or None
933 if the entry was not committed due to an error.
935 entry = copy.deepcopy(entry)
937 # Generate server IDs for this entry, and write generated server IDs
938 # from earlier entries into the message's fields, as appropriate. The
939 # ID generation state is stored in 'commit_session'.
940 self._RewriteIdsAsServerIds(entry, cache_guid, commit_session)
942 # Perform the optimistic concurrency check on the entry's version number.
943 # Clients are not allowed to commit unless they indicate that they've seen
944 # the most recent version of an object.
945 if not self._CheckVersionForCommit(entry):
948 # Check the validity of the parent ID; it must exist at this point.
949 # TODO(nick): Implement cycle detection and resolution.
950 if not self._CheckParentIdForCommit(entry):
953 self._CopyOverImmutableFields(entry);
955 # At this point, the commit is definitely going to happen.
957 # Deletion works by storing a limited record for an entry, called a
958 # tombstone. A sync server must track deleted IDs forever, since it does
959 # not keep track of client knowledge (there's no deletion ACK event).
961 def MakeTombstone(id_string, datatype):
962 """Make a tombstone entry that will replace the entry being deleted.
965 id_string: Index of the SyncEntity to be deleted.
967 A new SyncEntity reflecting the fact that the entry is deleted.
969 # Only the ID, version and deletion state are preserved on a tombstone.
970 tombstone = sync_pb2.SyncEntity()
971 tombstone.id_string = id_string
972 tombstone.deleted = True
974 tombstone.specifics.CopyFrom(GetDefaultEntitySpecifics(datatype))
977 def IsChild(child_id):
978 """Check if a SyncEntity is a child of entry, or any of its children.
981 child_id: Index of the SyncEntity that is a possible child of entry.
983 True if it is a child; false otherwise.
985 if child_id not in self._entries:
987 if self._entries[child_id].parent_id_string == entry.id_string:
989 return IsChild(self._entries[child_id].parent_id_string)
991 # Identify any children entry might have.
992 child_ids = [child.id_string for child in self._entries.itervalues()
993 if IsChild(child.id_string)]
995 # Mark all children that were identified as deleted.
996 for child_id in child_ids:
997 datatype = GetEntryType(self._entries[child_id])
998 self._SaveEntry(MakeTombstone(child_id, datatype))
1000 # Delete entry itself.
1001 datatype = GetEntryType(self._entries[entry.id_string])
1002 entry = MakeTombstone(entry.id_string, datatype)
1004 # Comments in sync.proto detail how the representation of positional
1007 # We've almost fully deprecated the 'insert_after_item_id' field.
1008 # The 'position_in_parent' field is also deprecated, but as of Jan 2013
1009 # is still in common use. The 'unique_position' field is the latest
1010 # and greatest in positioning technology.
1012 # This server supports 'position_in_parent' and 'unique_position'.
1013 self._WritePosition(entry, entry.parent_id_string)
1015 # Preserve the originator info, which the client is not required to send
1017 base_entry = self._entries.get(entry.id_string)
1018 if base_entry and not entry.HasField('originator_cache_guid'):
1019 entry.originator_cache_guid = base_entry.originator_cache_guid
1020 entry.originator_client_item_id = base_entry.originator_client_item_id
1022 # Store the current time since the Unix epoch in milliseconds.
1023 entry.mtime = (int((time.mktime(time.gmtime()) -
1024 (time.mktime(FIRST_DAY_UNIX_TIME_EPOCH) - ONE_DAY_SECONDS))*1000))
1026 # Commit the change. This also updates the version number.
1027 self._SaveEntry(entry)
1030 def _RewriteVersionInId(self, id_string):
1031 """Rewrites an ID so that its migration version becomes current."""
1032 parsed_id = self._ExtractIdInfo(id_string)
1035 datatype, old_migration_version, inner_id = parsed_id
1036 return self._MakeCurrentId(datatype, inner_id)
1038 def TriggerMigration(self, datatypes):
1039 """Cause a migration to occur for a set of datatypes on this account.
1041 Clients will see the MIGRATION_DONE error for these datatypes until they
1044 versions_to_remap = self.migration_history.Bump(datatypes)
1045 all_entries = self._entries.values()
1046 self._entries.clear()
1047 for entry in all_entries:
1048 new_id = self._RewriteVersionInId(entry.id_string)
1049 entry.id_string = new_id
1050 if entry.HasField('parent_id_string'):
1051 entry.parent_id_string = self._RewriteVersionInId(
1052 entry.parent_id_string)
1053 self._entries[entry.id_string] = entry
1055 def TriggerSyncTabFavicons(self):
1056 """Set the 'sync_tab_favicons' field to this account's nigori node.
1058 If the field is not currently set, will write a new nigori node entry
1059 with the field set. Else does nothing.
1062 nigori_tag = "google_chrome_nigori"
1063 nigori_original = self._entries.get(self._ServerTagToId(nigori_tag))
1064 if (nigori_original.specifics.nigori.sync_tab_favicons):
1066 nigori_new = copy.deepcopy(nigori_original)
1067 nigori_new.specifics.nigori.sync_tabs = True
1068 self._SaveEntry(nigori_new)
1070 def TriggerCreateSyncedBookmarks(self):
1071 """Create the Synced Bookmarks folder under the Bookmarks permanent item.
1073 Clients will then receive the Synced Bookmarks folder on future
1074 GetUpdates, and new bookmarks can be added within the Synced Bookmarks
1078 synced_bookmarks_spec, = [spec for spec in self._PERMANENT_ITEM_SPECS
1079 if spec.name == "Synced Bookmarks"]
1080 self._CreatePermanentItem(synced_bookmarks_spec)
1082 def TriggerEnableKeystoreEncryption(self):
1083 """Create the keystore_encryption experiment entity and enable it.
1085 A new entity within the EXPERIMENTS datatype is created with the unique
1086 client tag "keystore_encryption" if it doesn't already exist. The
1087 keystore_encryption message is then filled with |enabled| set to true.
1090 experiment_id = self._ServerTagToId("google_chrome_experiments")
1091 keystore_encryption_id = self._ClientTagToId(
1093 KEYSTORE_ENCRYPTION_EXPERIMENT_TAG)
1094 keystore_entry = self._entries.get(keystore_encryption_id)
1095 if keystore_entry is None:
1096 keystore_entry = sync_pb2.SyncEntity()
1097 keystore_entry.id_string = keystore_encryption_id
1098 keystore_entry.name = "Keystore Encryption"
1099 keystore_entry.client_defined_unique_tag = (
1100 KEYSTORE_ENCRYPTION_EXPERIMENT_TAG)
1101 keystore_entry.folder = False
1102 keystore_entry.deleted = False
1103 keystore_entry.specifics.CopyFrom(GetDefaultEntitySpecifics(EXPERIMENTS))
1104 self._WritePosition(keystore_entry, experiment_id)
1106 keystore_entry.specifics.experiments.keystore_encryption.enabled = True
1108 self._SaveEntry(keystore_entry)
1110 def TriggerRotateKeystoreKeys(self):
1111 """Rotate the current set of keystore encryption keys.
1113 |self._keys| will have a new random encryption key appended to it. We touch
1114 the nigori node so that each client will receive the new encryption keys
1118 # Add a new encryption key.
1119 self._keys += [MakeNewKeystoreKey(), ]
1121 # Increment the nigori node's timestamp, so clients will get the new keys
1122 # on their next GetUpdates (any time the nigori node is sent back, we also
1123 # send back the keystore keys).
1124 nigori_tag = "google_chrome_nigori"
1125 self._SaveEntry(self._entries.get(self._ServerTagToId(nigori_tag)))
1127 def TriggerAcknowledgeManagedUsers(self):
1128 """Set the "acknowledged" flag for any managed user entities that don't have
1132 if not self.acknowledge_managed_users:
1135 managed_users = [copy.deepcopy(entry) for entry in self._entries.values()
1136 if entry.specifics.HasField('managed_user')
1137 and not entry.specifics.managed_user.acknowledged]
1138 for user in managed_users:
1139 user.specifics.managed_user.acknowledged = True
1140 self._SaveEntry(user)
1142 def TriggerEnablePreCommitGetUpdateAvoidance(self):
1143 """Sets the experiment to enable pre-commit GetUpdate avoidance."""
1144 experiment_id = self._ServerTagToId("google_chrome_experiments")
1145 pre_commit_gu_avoidance_id = self._ClientTagToId(
1147 PRE_COMMIT_GU_AVOIDANCE_EXPERIMENT_TAG)
1148 entry = self._entries.get(pre_commit_gu_avoidance_id)
1150 entry = sync_pb2.SyncEntity()
1151 entry.id_string = pre_commit_gu_avoidance_id
1152 entry.name = "Pre-commit GU avoidance"
1153 entry.client_defined_unique_tag = PRE_COMMIT_GU_AVOIDANCE_EXPERIMENT_TAG
1154 entry.folder = False
1155 entry.deleted = False
1156 entry.specifics.CopyFrom(GetDefaultEntitySpecifics(EXPERIMENTS))
1157 self._WritePosition(entry, experiment_id)
1158 entry.specifics.experiments.pre_commit_update_avoidance.enabled = True
1159 self._SaveEntry(entry)
1161 def SetInducedError(self, error, error_frequency,
1162 sync_count_before_errors):
1163 self.induced_error = error
1164 self.induced_error_frequency = error_frequency
1165 self.sync_count_before_errors = sync_count_before_errors
1167 def GetInducedError(self):
1168 return self.induced_error
1170 def AddSyncedNotification(self, serialized_notification):
1171 """Adds a synced notification to the server data.
1173 The notification will be delivered to the client on the next GetUpdates
1177 serialized_notification: A serialized CoalescedSyncedNotification.
1180 The string representation of the added SyncEntity.
1183 ClientNotConnectedError: if the client has not yet connected to this
1186 # A unique string used wherever a unique ID for this notification is
1188 unique_notification_id = str(uuid.uuid4())
1190 specifics = self._CreateSyncedNotificationEntitySpecifics(
1191 unique_notification_id, serialized_notification)
1193 # Create the root SyncEntity representing a single notification.
1194 entity = sync_pb2.SyncEntity()
1195 entity.specifics.CopyFrom(specifics)
1196 entity.parent_id_string = self._ServerTagToId(
1197 'google_chrome_synced_notifications')
1198 entity.name = 'Synced notification added for testing'
1199 entity.version = self._GetNextVersionNumber()
1201 entity.client_defined_unique_tag = self._CreateSyncedNotificationClientTag(
1202 specifics.synced_notification.coalesced_notification.key)
1203 entity.id_string = self._ClientTagToId(GetEntryType(entity),
1204 entity.client_defined_unique_tag)
1206 self._entries[entity.id_string] = copy.deepcopy(entity)
1208 return google.protobuf.text_format.MessageToString(entity)
1210 def _GetNextVersionNumber(self):
1211 """Set the version to one more than the greatest version number seen."""
1212 entries = sorted(self._entries.values(), key=operator.attrgetter('version'))
1213 if len(entries) < 1:
1214 raise ClientNotConnectedError
1215 return entries[-1].version + 1
1217 def _CreateSyncedNotificationEntitySpecifics(self, unique_id,
1218 serialized_notification):
1219 """Create the EntitySpecifics proto for a synced notification."""
1220 coalesced = synced_notification_data_pb2.CoalescedSyncedNotification()
1221 google.protobuf.text_format.Merge(serialized_notification, coalesced)
1223 # Override the provided key so that we have a unique one.
1224 coalesced.key = unique_id
1226 specifics = sync_pb2.EntitySpecifics()
1227 notification_specifics = \
1228 synced_notification_specifics_pb2.SyncedNotificationSpecifics()
1229 notification_specifics.coalesced_notification.CopyFrom(coalesced)
1230 specifics.synced_notification.CopyFrom(notification_specifics)
1234 def _CreateSyncedNotificationClientTag(self, key):
1235 """Create the client_defined_unique_tag value for a SyncedNotification.
1238 key: The entity used to create the client tag.
1241 The string value of the to be used as the client_defined_unique_tag.
1243 serialized_type = sync_pb2.EntitySpecifics()
1244 specifics = synced_notification_specifics_pb2.SyncedNotificationSpecifics()
1245 serialized_type.synced_notification.CopyFrom(specifics)
1246 hash_input = serialized_type.SerializeToString() + key
1247 return base64.b64encode(hashlib.sha1(hash_input).digest())
1249 def AddSyncedNotificationAppInfo(self, app_info):
1250 """Adds an app info struct to the server data.
1252 The notification will be delivered to the client on the next GetUpdates
1256 app_info: A serialized AppInfo.
1259 The string representation of the added SyncEntity.
1262 ClientNotConnectedError: if the client has not yet connected to this
1265 specifics = self._CreateSyncedNotificationAppInfoEntitySpecifics(app_info)
1267 # Create the root SyncEntity representing a single app info protobuf.
1268 entity = sync_pb2.SyncEntity()
1269 entity.specifics.CopyFrom(specifics)
1270 entity.parent_id_string = self._ServerTagToId(
1271 'google_chrome_synced_notification_app_info')
1272 entity.name = 'App info added for testing'
1273 entity.version = self._GetNextVersionNumber()
1275 # App Infos do not have a strong id, it only needs to be unique.
1276 entity.client_defined_unique_tag = "foo"
1277 entity.id_string = "foo"
1279 self._entries[entity.id_string] = copy.deepcopy(entity)
1281 print "entity before exit is ", entity
1283 return google.protobuf.text_format.MessageToString(entity)
1285 def _CreateSyncedNotificationAppInfoEntitySpecifics(
1286 self, synced_notification_app_info):
1287 """Create the EntitySpecifics proto for a synced notification app info."""
1288 # Create a single, empty app_info object
1290 synced_notification_app_info_specifics_pb2.SyncedNotificationAppInfo()
1291 # Fill the app_info object from the text format protobuf.
1292 google.protobuf.text_format.Merge(synced_notification_app_info, app_info)
1294 # Create a new specifics object with a contained app_info
1295 specifics = sync_pb2.EntitySpecifics()
1296 app_info_specifics = \
1297 synced_notification_app_info_specifics_pb2.\
1298 SyncedNotificationAppInfoSpecifics()
1300 # Copy the app info from the text format protobuf
1301 contained_app_info = app_info_specifics.synced_notification_app_info.add()
1302 contained_app_info.CopyFrom(app_info)
1304 # And put the new app_info_specifics into the specifics before returning.
1305 specifics.synced_notification_app_info.CopyFrom(app_info_specifics)
1309 class TestServer(object):
1310 """An object to handle requests for one (and only one) Chrome Sync account.
1312 TestServer consumes the sync command messages that are the outermost
1313 layers of the protocol, performs the corresponding actions on its
1314 SyncDataModel, and constructs an appropriate response message.
1318 # The implementation supports exactly one account; its state is here.
1319 self.account = SyncDataModel()
1320 self.account_lock = threading.Lock()
1321 # Clients that have talked to us: a map from the full client ID
1324 self.client_name_generator = ('+' * times + chr(c)
1325 for times in xrange(0, sys.maxint) for c in xrange(ord('A'), ord('Z')))
1326 self.transient_error = False
1328 # Gaia OAuth2 Token fields and their default values.
1329 self.response_code = 200
1330 self.request_token = 'rt1'
1331 self.access_token = 'at1'
1332 self.expires_in = 3600
1333 self.token_type = 'Bearer'
1334 # The ClientCommand to send back on each ServerToClientResponse. If set to
1335 # None, no ClientCommand should be sent.
1336 self._client_command = None
1339 def GetShortClientName(self, query):
1340 parsed = cgi.parse_qs(query[query.find('?')+1:])
1341 client_id = parsed.get('client_id')
1344 client_id = client_id[0]
1345 if client_id not in self.clients:
1346 self.clients[client_id] = self.client_name_generator.next()
1347 return self.clients[client_id]
1349 def CheckStoreBirthday(self, request):
1350 """Raises StoreBirthdayError if the request's birthday is a mismatch."""
1351 if not request.HasField('store_birthday'):
1353 if self.account.StoreBirthday() != request.store_birthday:
1354 raise StoreBirthdayError
1356 def CheckTransientError(self):
1357 """Raises TransientError if transient_error variable is set."""
1358 if self.transient_error:
1359 raise TransientError
1361 def CheckSendError(self):
1362 """Raises SyncInducedError if needed."""
1363 if (self.account.induced_error.error_type !=
1364 sync_enums_pb2.SyncEnums.UNKNOWN):
1365 # Always means return the given error for all requests.
1366 if self.account.induced_error_frequency == ERROR_FREQUENCY_ALWAYS:
1367 raise SyncInducedError
1368 # This means the FIRST 2 requests of every 3 requests
1369 # return an error. Don't switch the order of failures. There are
1370 # test cases that rely on the first 2 being the failure rather than
1372 elif (self.account.induced_error_frequency ==
1373 ERROR_FREQUENCY_TWO_THIRDS):
1374 if (((self.sync_count -
1375 self.account.sync_count_before_errors) % 3) != 0):
1376 raise SyncInducedError
1378 raise InducedErrorFrequencyNotDefined
1380 def HandleMigrate(self, path):
1381 query = urlparse.urlparse(path)[4]
1383 self.account_lock.acquire()
1385 datatypes = [DataTypeStringToSyncTypeLoose(x)
1386 for x in urlparse.parse_qs(query).get('type',[])]
1388 self.account.TriggerMigration(datatypes)
1389 response = 'Migrated datatypes %s' % (
1390 ' and '.join(SyncTypeToString(x).upper() for x in datatypes))
1392 response = 'Please specify one or more <i>type=name</i> parameters'
1394 except DataTypeIdNotRecognized, error:
1395 response = 'Could not interpret datatype name'
1398 self.account_lock.release()
1399 return (code, '<html><title>Migration: %d</title><H1>%d %s</H1></html>' %
1400 (code, code, response))
1402 def HandleSetInducedError(self, path):
1403 query = urlparse.urlparse(path)[4]
1404 self.account_lock.acquire()
1406 response = 'Success'
1407 error = sync_pb2.ClientToServerResponse.Error()
1409 error_type = urlparse.parse_qs(query)['error']
1410 action = urlparse.parse_qs(query)['action']
1411 error.error_type = int(error_type[0])
1412 error.action = int(action[0])
1414 error.url = (urlparse.parse_qs(query)['url'])[0]
1418 error.error_description =(
1419 (urlparse.parse_qs(query)['error_description'])[0])
1421 error.error_description = ''
1423 error_frequency = int((urlparse.parse_qs(query)['frequency'])[0])
1425 error_frequency = ERROR_FREQUENCY_ALWAYS
1426 self.account.SetInducedError(error, error_frequency, self.sync_count)
1427 response = ('Error = %d, action = %d, url = %s, description = %s' %
1428 (error.error_type, error.action,
1430 error.error_description))
1432 response = 'Could not parse url'
1435 self.account_lock.release()
1436 return (code, '<html><title>SetError: %d</title><H1>%d %s</H1></html>' %
1437 (code, code, response))
1439 def HandleCreateBirthdayError(self):
1440 self.account.ResetStoreBirthday()
1443 '<html><title>Birthday error</title><H1>Birthday error</H1></html>')
1445 def HandleSetTransientError(self):
1446 self.transient_error = True
1449 '<html><title>Transient error</title><H1>Transient error</H1></html>')
1451 def HandleSetSyncTabFavicons(self):
1452 """Set 'sync_tab_favicons' field of the nigori node for this account."""
1453 self.account.TriggerSyncTabFavicons()
1456 '<html><title>Tab Favicons</title><H1>Tab Favicons</H1></html>')
1458 def HandleCreateSyncedBookmarks(self):
1459 """Create the Synced Bookmarks folder under Bookmarks."""
1460 self.account.TriggerCreateSyncedBookmarks()
1463 '<html><title>Synced Bookmarks</title><H1>Synced Bookmarks</H1></html>')
1465 def HandleEnableKeystoreEncryption(self):
1466 """Enables the keystore encryption experiment."""
1467 self.account.TriggerEnableKeystoreEncryption()
1470 '<html><title>Enable Keystore Encryption</title>'
1471 '<H1>Enable Keystore Encryption</H1></html>')
1473 def HandleRotateKeystoreKeys(self):
1474 """Rotate the keystore encryption keys."""
1475 self.account.TriggerRotateKeystoreKeys()
1478 '<html><title>Rotate Keystore Keys</title>'
1479 '<H1>Rotate Keystore Keys</H1></html>')
1481 def HandleEnableManagedUserAcknowledgement(self):
1482 """Enable acknowledging newly created managed users."""
1483 self.account.acknowledge_managed_users = True
1486 '<html><title>Enable Managed User Acknowledgement</title>'
1487 '<h1>Enable Managed User Acknowledgement</h1></html>')
1489 def HandleEnablePreCommitGetUpdateAvoidance(self):
1490 """Enables the pre-commit GU avoidance experiment."""
1491 self.account.TriggerEnablePreCommitGetUpdateAvoidance()
1494 '<html><title>Enable pre-commit GU avoidance</title>'
1495 '<H1>Enable pre-commit GU avoidance</H1></html>')
1497 def HandleCommand(self, query, raw_request):
1498 """Decode and handle a sync command from a raw input of bytes.
1500 This is the main entry point for this class. It is safe to call this
1501 method from multiple threads.
1504 raw_request: An iterable byte sequence to be interpreted as a sync
1507 A tuple (response_code, raw_response); the first value is an HTTP
1508 result code, while the second value is a string of bytes which is the
1509 serialized reply to the command.
1511 self.account_lock.acquire()
1512 self.sync_count += 1
1513 def print_context(direction):
1514 print '[Client %s %s %s.py]' % (self.GetShortClientName(query), direction,
1518 request = sync_pb2.ClientToServerMessage()
1519 request.MergeFromString(raw_request)
1520 contents = request.message_contents
1522 response = sync_pb2.ClientToServerResponse()
1523 response.error_code = sync_enums_pb2.SyncEnums.SUCCESS
1525 if self._client_command:
1526 response.client_command.CopyFrom(self._client_command)
1528 self.CheckStoreBirthday(request)
1529 response.store_birthday = self.account.store_birthday
1530 self.CheckTransientError()
1531 self.CheckSendError()
1535 if contents == sync_pb2.ClientToServerMessage.AUTHENTICATE:
1536 print 'Authenticate'
1537 # We accept any authentication token, and support only one account.
1538 # TODO(nick): Mock out the GAIA authentication as well; hook up here.
1539 response.authenticate.user.email = 'syncjuser@chromium'
1540 response.authenticate.user.display_name = 'Sync J User'
1541 elif contents == sync_pb2.ClientToServerMessage.COMMIT:
1542 print 'Commit %d item(s)' % len(request.commit.entries)
1543 self.HandleCommit(request.commit, response.commit)
1544 elif contents == sync_pb2.ClientToServerMessage.GET_UPDATES:
1546 self.HandleGetUpdates(request.get_updates, response.get_updates)
1548 print '%d update(s)' % len(response.get_updates.entries)
1550 print 'Unrecognizable sync request!'
1551 return (400, None) # Bad request.
1552 return (200, response.SerializeToString())
1553 except MigrationDoneError, error:
1555 print 'MIGRATION_DONE: <%s>' % (ShortDatatypeListSummary(error.datatypes))
1556 response = sync_pb2.ClientToServerResponse()
1557 response.store_birthday = self.account.store_birthday
1558 response.error_code = sync_enums_pb2.SyncEnums.MIGRATION_DONE
1559 response.migrated_data_type_id[:] = [
1560 SyncTypeToProtocolDataTypeId(x) for x in error.datatypes]
1561 return (200, response.SerializeToString())
1562 except StoreBirthdayError, error:
1564 print 'NOT_MY_BIRTHDAY'
1565 response = sync_pb2.ClientToServerResponse()
1566 response.store_birthday = self.account.store_birthday
1567 response.error_code = sync_enums_pb2.SyncEnums.NOT_MY_BIRTHDAY
1568 return (200, response.SerializeToString())
1569 except TransientError, error:
1570 ### This is deprecated now. Would be removed once test cases are removed.
1572 print 'TRANSIENT_ERROR'
1573 response.store_birthday = self.account.store_birthday
1574 response.error_code = sync_enums_pb2.SyncEnums.TRANSIENT_ERROR
1575 return (200, response.SerializeToString())
1576 except SyncInducedError, error:
1578 print 'INDUCED_ERROR'
1579 response.store_birthday = self.account.store_birthday
1580 error = self.account.GetInducedError()
1581 response.error.error_type = error.error_type
1582 response.error.url = error.url
1583 response.error.error_description = error.error_description
1584 response.error.action = error.action
1585 return (200, response.SerializeToString())
1587 self.account_lock.release()
1589 def HandleCommit(self, commit_message, commit_response):
1590 """Respond to a Commit request by updating the user's account state.
1592 Commit attempts stop after the first error, returning a CONFLICT result
1593 for any unattempted entries.
1596 commit_message: A sync_pb.CommitMessage protobuf holding the content
1597 of the client's request.
1598 commit_response: A sync_pb.CommitResponse protobuf into which a reply
1599 to the client request will be written.
1601 commit_response.SetInParent()
1602 batch_failure = False
1603 session = {} # Tracks ID renaming during the commit operation.
1604 guid = commit_message.cache_guid
1606 self.account.ValidateCommitEntries(commit_message.entries)
1608 for entry in commit_message.entries:
1610 if not batch_failure:
1611 # Try to commit the change to the account.
1612 server_entry = self.account.CommitEntry(entry, guid, session)
1614 # An entryresponse is returned in both success and failure cases.
1615 reply = commit_response.entryresponse.add()
1616 if not server_entry:
1617 reply.response_type = sync_pb2.CommitResponse.CONFLICT
1618 reply.error_message = 'Conflict.'
1619 batch_failure = True # One failure halts the batch.
1621 reply.response_type = sync_pb2.CommitResponse.SUCCESS
1622 # These are the properties that the server is allowed to override
1623 # during commit; the client wants to know their values at the end
1625 reply.id_string = server_entry.id_string
1626 if not server_entry.deleted:
1627 # Note: the production server doesn't actually send the
1628 # parent_id_string on commit responses, so we don't either.
1629 reply.position_in_parent = server_entry.position_in_parent
1630 reply.version = server_entry.version
1631 reply.name = server_entry.name
1632 reply.non_unique_name = server_entry.non_unique_name
1634 reply.version = entry.version + 1
1636 def HandleGetUpdates(self, update_request, update_response):
1637 """Respond to a GetUpdates request by querying the user's account.
1640 update_request: A sync_pb.GetUpdatesMessage protobuf holding the content
1641 of the client's request.
1642 update_response: A sync_pb.GetUpdatesResponse protobuf into which a reply
1643 to the client request will be written.
1645 update_response.SetInParent()
1646 update_sieve = UpdateSieve(update_request, self.account.migration_history)
1648 print CallerInfoToString(update_request.caller_info.source),
1649 print update_sieve.SummarizeRequest()
1651 update_sieve.CheckMigrationState()
1653 new_timestamp, entries, remaining = self.account.GetChanges(update_sieve)
1655 update_response.changes_remaining = remaining
1656 sending_nigori_node = False
1657 for entry in entries:
1658 if entry.name == 'Nigori':
1659 sending_nigori_node = True
1660 reply = update_response.entries.add()
1661 reply.CopyFrom(entry)
1662 update_sieve.SaveProgress(new_timestamp, update_response)
1664 if update_request.need_encryption_key or sending_nigori_node:
1665 update_response.encryption_keys.extend(self.account.GetKeystoreKeys())
1667 def HandleGetOauth2Token(self):
1668 return (int(self.response_code),
1670 ' \"refresh_token\": \"' + self.request_token + '\",\n'
1671 ' \"access_token\": \"' + self.access_token + '\",\n'
1672 ' \"expires_in\": ' + str(self.expires_in) + ',\n'
1673 ' \"token_type\": \"' + self.token_type +'\"\n'
1676 def HandleSetOauth2Token(self, response_code, request_token, access_token,
1677 expires_in, token_type):
1678 if response_code != 0:
1679 self.response_code = response_code
1680 if request_token != '':
1681 self.request_token = request_token
1682 if access_token != '':
1683 self.access_token = access_token
1685 self.expires_in = expires_in
1686 if token_type != '':
1687 self.token_type = token_type
1690 '<html><title>Set OAuth2 Token</title>'
1691 '<H1>This server will now return the OAuth2 Token:</H1>'
1692 '<p>response_code: ' + str(self.response_code) + '</p>'
1693 '<p>request_token: ' + self.request_token + '</p>'
1694 '<p>access_token: ' + self.access_token + '</p>'
1695 '<p>expires_in: ' + str(self.expires_in) + '</p>'
1696 '<p>token_type: ' + self.token_type + '</p>'
1699 def CustomizeClientCommand(self, sessions_commit_delay_seconds):
1700 """Customizes the value of the ClientCommand of ServerToClientResponse.
1702 Currently, this only allows for changing the sessions_commit_delay_seconds
1703 field. This is useful for testing in conjunction with
1704 AddSyncedNotification so that synced notifications are seen immediately
1705 after triggering them with an HTTP call to the test server.
1708 sessions_commit_delay_seconds: The desired sync delay time for sessions.
1710 if not self._client_command:
1711 self._client_command = client_commands_pb2.ClientCommand()
1713 self._client_command.sessions_commit_delay_seconds = \
1714 sessions_commit_delay_seconds
1715 return self._client_command