UIDMaps: prevent from leaving a truncated map file

If the map file is not properly written (e.g. due to unexpected kill)
offlineimap might wrongly consider some UIDs to have been deleted from the local
side which could lead to data loss.

Use a temporary map file rather than writing to the map file directly.

Github-ref: https://github.com/OfflineIMAP/offlineimap/issues/380
Signed-off-by: Nicolas Sebrecht <nicolas.s-dev@laposte.net>
This commit is contained in:
Nicolas Sebrecht 2016-10-24 03:19:26 +02:00
parent 0a635bd236
commit 2ac68d82aa

View File

@ -16,6 +16,8 @@
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
import os.path import os.path
import shutil
from os import fsync, unlink
from sys import exc_info from sys import exc_info
from threading import Lock from threading import Lock
import six import six
@ -31,6 +33,7 @@ class MappedIMAPFolder(IMAPFolder):
be an IMAPFolder. be an IMAPFolder.
Instance variables (self.): Instance variables (self.):
dofsync: boolean for fsync calls.
r2l: dict mapping message uids: self.r2l[remoteuid]=localuid r2l: dict mapping message uids: self.r2l[remoteuid]=localuid
l2r: dict mapping message uids: self.r2l[localuid]=remoteuid l2r: dict mapping message uids: self.r2l[localuid]=remoteuid
#TODO: what is the difference, how are they used? #TODO: what is the difference, how are they used?
@ -39,18 +42,33 @@ class MappedIMAPFolder(IMAPFolder):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
IMAPFolder.__init__(self, *args, **kwargs) IMAPFolder.__init__(self, *args, **kwargs)
self.dofsync = self.config.getdefaultboolean("general", "fsync", True)
self.maplock = Lock() self.maplock = Lock()
(self.diskr2l, self.diskl2r) = self._loadmaps() self.diskr2l, self.diskl2r = self._loadmaps()
self.r2l, self.l2r = None, None
# Representing the local IMAP Folder using local UIDs.
# XXX: This should be removed since we inherit from IMAPFolder.
# See commit 3ce514e92ba7 to know more.
self._mb = IMAPFolder(*args, **kwargs) self._mb = IMAPFolder(*args, **kwargs)
"""Representing the local IMAP Folder using local UIDs"""
def _getmapfilename(self): def _getmapfilename(self):
return os.path.join(self.repository.getmapdir(), return os.path.join(self.repository.getmapdir(),
self.getfolderbasename()) self.getfolderbasename())
def _loadmaps(self): def _loadmaps(self):
with self.maplock:
mapfilename = self._getmapfilename() mapfilename = self._getmapfilename()
mapfilenametmp = "%s.tmp"% mapfilename
mapfilenamelock = "%s.lock"% mapfilename
with self.maplock and open(mapfilenamelock, 'w') as mapfilelock:
try:
fnctl.lockf(mapfilelock, fnctl.LOCK_EX) # Blocks until acquired.
except NameError:
pass # Windows...
if os.path.exists(mapfilenametmp):
self.ui.warn("a previous run might have leave the UIDMaps file"
" in incorrect state; some sync operations might be done"
" again and some emails might become duplicated.")
unlink(mapfilenametmp)
if not os.path.exists(mapfilename): if not os.path.exists(mapfilename):
return ({}, {}) return ({}, {})
file = open(mapfilename, 'rt') file = open(mapfilename, 'rt')
@ -77,8 +95,10 @@ class MappedIMAPFolder(IMAPFolder):
def _savemaps(self): def _savemaps(self):
mapfilename = self._getmapfilename() mapfilename = self._getmapfilename()
# Do not use the map file directly to prevent from leaving it truncated.
mapfilenametmp = "%s.tmp"% mapfilename
mapfilenamelock = "%s.lock"% mapfilename mapfilenamelock = "%s.lock"% mapfilename
with open(mapfilenamelock, 'w') as mapfilelock: with self.maplock and open(mapfilenamelock, 'w') as mapfilelock:
# The "account" lock already prevents from multiple access by # The "account" lock already prevents from multiple access by
# different processes. However, we still need to protect for # different processes. However, we still need to protect for
# multiple access from different threads. # multiple access from different threads.
@ -86,10 +106,13 @@ class MappedIMAPFolder(IMAPFolder):
fnctl.lockf(mapfilelock, fnctl.LOCK_EX) # Blocks until acquired. fnctl.lockf(mapfilelock, fnctl.LOCK_EX) # Blocks until acquired.
except NameError: except NameError:
pass # Windows... pass # Windows...
with open(mapfilename, 'wt') as mapfilefd: with open(mapfilenametmp, 'wt') as mapfilefd:
for (key, value) in self.diskl2r.items(): for (key, value) in self.diskl2r.items():
mapfilefd.write("%d:%d\n"% (key, value)) mapfilefd.write("%d:%d\n"% (key, value))
if self.dofsync is True:
fsync(mapfilefd)
# The lock is released when the file descriptor ends. # The lock is released when the file descriptor ends.
shutil.move(mapfilenametmp, mapfilename)
def _uidlist(self, mapping, items): def _uidlist(self, mapping, items):
try: try:
@ -140,6 +163,7 @@ class MappedIMAPFolder(IMAPFolder):
# Interface from BaseFolder # Interface from BaseFolder
def uidexists(self, ruid): def uidexists(self, ruid):
"""Checks if the (remote) UID exists in this Folder""" """Checks if the (remote) UID exists in this Folder"""
# This implementation overrides the one in BaseFolder, as it is # This implementation overrides the one in BaseFolder, as it is
# much more efficient for the mapped case. # much more efficient for the mapped case.
return ruid in self.r2l return ruid in self.r2l
@ -147,7 +171,9 @@ class MappedIMAPFolder(IMAPFolder):
# Interface from BaseFolder # Interface from BaseFolder
def getmessageuidlist(self): def getmessageuidlist(self):
"""Gets a list of (remote) UIDs. """Gets a list of (remote) UIDs.
You may have to call cachemessagelist() before calling this function!""" You may have to call cachemessagelist() before calling this function!"""
# This implementation overrides the one in BaseFolder, as it is # This implementation overrides the one in BaseFolder, as it is
# much more efficient for the mapped case. # much more efficient for the mapped case.
return self.r2l.keys() return self.r2l.keys()
@ -155,16 +181,19 @@ class MappedIMAPFolder(IMAPFolder):
# Interface from BaseFolder # Interface from BaseFolder
def getmessagecount(self): def getmessagecount(self):
"""Gets the number of messages in this folder. """Gets the number of messages in this folder.
You may have to call cachemessagelist() before calling this function!""" You may have to call cachemessagelist() before calling this function!"""
# This implementation overrides the one in BaseFolder, as it is # This implementation overrides the one in BaseFolder, as it is
# much more efficient for the mapped case. # much more efficient for the mapped case.
return len(self.r2l) return len(self.r2l)
# Interface from BaseFolder # Interface from BaseFolder
def getmessagelist(self): def getmessagelist(self):
"""Gets the current message list. This function's implementation """Gets the current message list.
is quite expensive for the mapped UID case. You must call
cachemessagelist() before calling this function!""" This function's implementation is quite expensive for the mapped UID
case. You must call cachemessagelist() before calling this function!"""
retval = {} retval = {}
localhash = self._mb.getmessagelist() localhash = self._mb.getmessagelist()
@ -208,6 +237,7 @@ class MappedIMAPFolder(IMAPFolder):
check against dryrun settings, so you need to ensure that check against dryrun settings, so you need to ensure that
savemessage is never called in a dryrun mode. savemessage is never called in a dryrun mode.
""" """
self.ui.savemessage('imap', uid, flags, self) self.ui.savemessage('imap', uid, flags, self)
# Mapped UID instances require the source to already have a # Mapped UID instances require the source to already have a
# positive UID, so simply return here. # positive UID, so simply return here.
@ -263,6 +293,7 @@ class MappedIMAPFolder(IMAPFolder):
:param new_uid: The old remote UID will be changed to a new :param new_uid: The old remote UID will be changed to a new
UID. The UIDMaps case handles this efficiently by simply UID. The UIDMaps case handles this efficiently by simply
changing the mappings file.""" changing the mappings file."""
if ruid not in self.r2l: if ruid not in self.r2l:
raise OfflineImapError("Cannot change unknown Maildir UID %s"% raise OfflineImapError("Cannot change unknown Maildir UID %s"%
ruid, OfflineImapError.ERROR.MESSAGE) ruid, OfflineImapError.ERROR.MESSAGE)