]> git.rkrishnan.org Git - tahoe-lafs/tahoe-lafs.git/blob - src/allmydata/util/fileutil.py
Improved error handling and cosmetics for ctypes calls on Windows.
[tahoe-lafs/tahoe-lafs.git] / src / allmydata / util / fileutil.py
1 """
2 Futz with files like a pro.
3 """
4
5 import sys, exceptions, os, stat, tempfile, time, binascii
6
7 if sys.platform == "win32":
8     from ctypes import WINFUNCTYPE, WinError, windll, POINTER, byref, c_ulonglong, \
9         create_unicode_buffer, get_last_error
10     from ctypes.wintypes import BOOL, DWORD, LPCWSTR, LPWSTR
11
12 from twisted.python import log
13
14 from pycryptopp.cipher.aes import AES
15
16
17 def rename(src, dst, tries=4, basedelay=0.1):
18     """ Here is a superkludge to workaround the fact that occasionally on
19     Windows some other process (e.g. an anti-virus scanner, a local search
20     engine, etc.) is looking at your file when you want to delete or move it,
21     and hence you can't.  The horrible workaround is to sit and spin, trying
22     to delete it, for a short time and then give up.
23
24     With the default values of tries and basedelay this can block for less
25     than a second.
26
27     @param tries: number of tries -- each time after the first we wait twice
28     as long as the previous wait
29     @param basedelay: how long to wait before the second try
30     """
31     for i in range(tries-1):
32         try:
33             return os.rename(src, dst)
34         except EnvironmentError, le:
35             # XXX Tighten this to check if this is a permission denied error (possibly due to another Windows process having the file open and execute the superkludge only in this case.
36             log.msg("XXX KLUDGE Attempting to move file %s => %s; got %s; sleeping %s seconds" % (src, dst, le, basedelay,))
37             time.sleep(basedelay)
38             basedelay *= 2
39     return os.rename(src, dst) # The last try.
40
41 def remove(f, tries=4, basedelay=0.1):
42     """ Here is a superkludge to workaround the fact that occasionally on
43     Windows some other process (e.g. an anti-virus scanner, a local search
44     engine, etc.) is looking at your file when you want to delete or move it,
45     and hence you can't.  The horrible workaround is to sit and spin, trying
46     to delete it, for a short time and then give up.
47
48     With the default values of tries and basedelay this can block for less
49     than a second.
50
51     @param tries: number of tries -- each time after the first we wait twice
52     as long as the previous wait
53     @param basedelay: how long to wait before the second try
54     """
55     try:
56         os.chmod(f, stat.S_IWRITE | stat.S_IEXEC | stat.S_IREAD)
57     except:
58         pass
59     for i in range(tries-1):
60         try:
61             return os.remove(f)
62         except EnvironmentError, le:
63             # XXX Tighten this to check if this is a permission denied error (possibly due to another Windows process having the file open and execute the superkludge only in this case.
64             if not os.path.exists(f):
65                 return
66             log.msg("XXX KLUDGE Attempting to remove file %s; got %s; sleeping %s seconds" % (f, le, basedelay,))
67             time.sleep(basedelay)
68             basedelay *= 2
69     return os.remove(f) # The last try.
70
71 class ReopenableNamedTemporaryFile:
72     """
73     This uses tempfile.mkstemp() to generate a secure temp file.  It then closes
74     the file, leaving a zero-length file as a placeholder.  You can get the
75     filename with ReopenableNamedTemporaryFile.name.  When the
76     ReopenableNamedTemporaryFile instance is garbage collected or its shutdown()
77     method is called, it deletes the file.
78     """
79     def __init__(self, *args, **kwargs):
80         fd, self.name = tempfile.mkstemp(*args, **kwargs)
81         os.close(fd)
82
83     def __repr__(self):
84         return "<%s instance at %x %s>" % (self.__class__.__name__, id(self), self.name)
85
86     def __str__(self):
87         return self.__repr__()
88
89     def __del__(self):
90         self.shutdown()
91
92     def shutdown(self):
93         remove(self.name)
94
95 class EncryptedTemporaryFile:
96     # not implemented: next, readline, readlines, xreadlines, writelines
97
98     def __init__(self):
99         self.file = tempfile.TemporaryFile()
100         self.key = os.urandom(16)  # AES-128
101
102     def _crypt(self, offset, data):
103         offset_big = offset // 16
104         offset_small = offset % 16
105         iv = binascii.unhexlify("%032x" % offset_big)
106         cipher = AES(self.key, iv=iv)
107         cipher.process("\x00"*offset_small)
108         return cipher.process(data)
109
110     def close(self):
111         self.file.close()
112
113     def flush(self):
114         self.file.flush()
115
116     def seek(self, offset, whence=0):  # 0 = SEEK_SET
117         self.file.seek(offset, whence)
118
119     def tell(self):
120         offset = self.file.tell()
121         return offset
122
123     def read(self, size=-1):
124         """A read must not follow a write, or vice-versa, without an intervening seek."""
125         index = self.file.tell()
126         ciphertext = self.file.read(size)
127         plaintext = self._crypt(index, ciphertext)
128         return plaintext
129
130     def write(self, plaintext):
131         """A read must not follow a write, or vice-versa, without an intervening seek.
132         If seeking and then writing causes a 'hole' in the file, the contents of the
133         hole are unspecified."""
134         index = self.file.tell()
135         ciphertext = self._crypt(index, plaintext)
136         self.file.write(ciphertext)
137
138     def truncate(self, newsize):
139         """Truncate or extend the file to 'newsize'. If it is extended, the contents after the
140         old end-of-file are unspecified. The file position after this operation is unspecified."""
141         self.file.truncate(newsize)
142
143
144 def make_dirs(dirname, mode=0777):
145     """
146     An idempotent version of os.makedirs().  If the dir already exists, do
147     nothing and return without raising an exception.  If this call creates the
148     dir, return without raising an exception.  If there is an error that
149     prevents creation or if the directory gets deleted after make_dirs() creates
150     it and before make_dirs() checks that it exists, raise an exception.
151     """
152     tx = None
153     try:
154         os.makedirs(dirname, mode)
155     except OSError, x:
156         tx = x
157
158     if not os.path.isdir(dirname):
159         if tx:
160             raise tx
161         raise exceptions.IOError, "unknown error prevented creation of directory, or deleted the directory immediately after creation: %s" % dirname # careful not to construct an IOError with a 2-tuple, as that has a special meaning...
162
163 def rm_dir(dirname):
164     """
165     A threadsafe and idempotent version of shutil.rmtree().  If the dir is
166     already gone, do nothing and return without raising an exception.  If this
167     call removes the dir, return without raising an exception.  If there is an
168     error that prevents deletion or if the directory gets created again after
169     rm_dir() deletes it and before rm_dir() checks that it is gone, raise an
170     exception.
171     """
172     excs = []
173     try:
174         os.chmod(dirname, stat.S_IWRITE | stat.S_IEXEC | stat.S_IREAD)
175         for f in os.listdir(dirname):
176             fullname = os.path.join(dirname, f)
177             if os.path.isdir(fullname):
178                 rm_dir(fullname)
179             else:
180                 remove(fullname)
181         os.rmdir(dirname)
182     except Exception, le:
183         # Ignore "No such file or directory"
184         if (not isinstance(le, OSError)) or le.args[0] != 2:
185             excs.append(le)
186
187     # Okay, now we've recursively removed everything, ignoring any "No
188     # such file or directory" errors, and collecting any other errors.
189
190     if os.path.exists(dirname):
191         if len(excs) == 1:
192             raise excs[0]
193         if len(excs) == 0:
194             raise OSError, "Failed to remove dir for unknown reason."
195         raise OSError, excs
196
197
198 def remove_if_possible(f):
199     try:
200         remove(f)
201     except:
202         pass
203
204 def du(basedir):
205     size = 0
206
207     for root, dirs, files in os.walk(basedir):
208         for f in files:
209             fn = os.path.join(root, f)
210             size += os.path.getsize(fn)
211
212     return size
213
214 def move_into_place(source, dest):
215     """Atomically replace a file, or as near to it as the platform allows.
216     The dest file may or may not exist."""
217     if "win32" in sys.platform.lower():
218         remove_if_possible(dest)
219     os.rename(source, dest)
220
221 def write_atomically(target, contents, mode="b"):
222     f = open(target+".tmp", "w"+mode)
223     try:
224         f.write(contents)
225     finally:
226         f.close()
227     move_into_place(target+".tmp", target)
228
229 def write(path, data, mode="wb"):
230     wf = open(path, mode)
231     try:
232         wf.write(data)
233     finally:
234         wf.close()
235
236 def read(path):
237     rf = open(path, "rb")
238     try:
239         return rf.read()
240     finally:
241         rf.close()
242
243 def put_file(path, inf):
244     precondition_abspath(path)
245
246     # TODO: create temporary file and move into place?
247     outf = open(path, "wb")
248     try:
249         while True:
250             data = inf.read(32768)
251             if not data:
252                 break
253             outf.write(data)
254     finally:
255         outf.close()
256
257
258 def precondition_abspath(path):
259     if not isinstance(path, unicode):
260         raise AssertionError("an abspath must be a Unicode string")
261
262     if sys.platform == "win32":
263         # This intentionally doesn't view absolute paths starting with a drive specification, or
264         # paths relative to the current drive, as acceptable.
265         if not path.startswith("\\\\"):
266             raise AssertionError("an abspath should be normalized using abspath_expanduser_unicode")
267     else:
268         # This intentionally doesn't view the path '~' or paths starting with '~/' as acceptable.
269         if not os.path.isabs(path):
270             raise AssertionError("an abspath should be normalized using abspath_expanduser_unicode")
271
272 # Work around <http://bugs.python.org/issue3426>. This code is adapted from
273 # <http://svn.python.org/view/python/trunk/Lib/ntpath.py?revision=78247&view=markup>
274 # with some simplifications.
275
276 _getfullpathname = None
277 try:
278     from nt import _getfullpathname
279 except ImportError:
280     pass
281
282 def abspath_expanduser_unicode(path, base=None):
283     """
284     Return the absolute version of a path. If 'base' is given and 'path' is relative,
285     the path will be expanded relative to 'base'.
286     'path' must be a Unicode string. 'base', if given, must be a Unicode string
287     corresponding to an absolute path as returned by a previous call to
288     abspath_expanduser_unicode.
289     """
290     if not isinstance(path, unicode):
291         raise AssertionError("paths must be Unicode strings")
292     if base is not None:
293         precondition_abspath(base)
294
295     path = expanduser(path)
296
297     if _getfullpathname:
298         # On Windows, os.path.isabs will incorrectly return True
299         # for paths without a drive letter (that are not UNC paths),
300         # e.g. "\\". See <http://bugs.python.org/issue1669539>.
301         try:
302             if base is None:
303                 path = _getfullpathname(path or u".")
304             else:
305                 path = _getfullpathname(os.path.join(base, path))
306         except OSError:
307             pass
308
309     if not os.path.isabs(path):
310         if base is None:
311             path = os.path.join(os.getcwdu(), path)
312         else:
313             path = os.path.join(base, path)
314
315     # We won't hit <http://bugs.python.org/issue5827> because
316     # there is always at least one Unicode path component.
317     path = os.path.normpath(path)
318
319     if sys.platform == "win32":
320         path = to_windows_long_path(path)
321
322     return path
323
324 def to_windows_long_path(path):
325     # '/' is normally a perfectly valid path component separator in Windows.
326     # However, when using the "\\?\" syntax it is not recognized, so we
327     # replace it with '\' here.
328     path = path.replace(u"/", u"\\")
329
330     # Note that other normalizations such as removing '.' and '..' should
331     # be done outside this function.
332
333     if path.startswith(u"\\\\?\\") or path.startswith(u"\\\\.\\"):
334         return path
335     elif path.startswith(u"\\\\"):
336         return u"\\\\?\\UNC\\" + path[2 :]
337     else:
338         return u"\\\\?\\" + path
339
340
341 have_GetDiskFreeSpaceExW = False
342 if sys.platform == "win32":
343     # <http://msdn.microsoft.com/en-us/library/windows/desktop/ms683188%28v=vs.85%29.aspx>
344     GetEnvironmentVariableW = WINFUNCTYPE(
345         DWORD,  LPCWSTR, LPWSTR, DWORD,
346         use_last_error=True
347     )(("GetEnvironmentVariableW", windll.kernel32))
348
349     try:
350         # <http://msdn.microsoft.com/en-us/library/aa383742%28v=VS.85%29.aspx>
351         PULARGE_INTEGER = POINTER(c_ulonglong)
352
353         # <http://msdn.microsoft.com/en-us/library/aa364937%28VS.85%29.aspx>
354         GetDiskFreeSpaceExW = WINFUNCTYPE(
355             BOOL,  LPCWSTR, PULARGE_INTEGER, PULARGE_INTEGER, PULARGE_INTEGER,
356             use_last_error=True
357         )(("GetDiskFreeSpaceExW", windll.kernel32))
358
359         have_GetDiskFreeSpaceExW = True
360     except Exception:
361         import traceback
362         traceback.print_exc()
363
364 def expanduser(path):
365     # os.path.expanduser is hopelessly broken for Unicode paths on Windows (ticket #1674).
366     if sys.platform == "win32":
367         return windows_expanduser(path)
368     else:
369         return os.path.expanduser(path)
370
371 def windows_expanduser(path):
372     if not path.startswith('~'):
373         return path
374
375     home_dir = windows_getenv(u'USERPROFILE')
376     if home_dir is None:
377         home_drive = windows_getenv(u'HOMEDRIVE')
378         home_path = windows_getenv(u'HOMEPATH')
379         if home_drive is None or home_path is None:
380             raise OSError("Could not find home directory: neither %USERPROFILE% nor (%HOMEDRIVE% and %HOMEPATH%) are set.")
381         home_dir = os.path.join(home_drive, home_path)
382
383     if path == '~':
384         return home_dir
385     elif path.startswith('~/') or path.startswith('~\\'):
386         return os.path.join(home_dir, path[2 :])
387     else:
388         return path
389
390 # <https://msdn.microsoft.com/en-us/library/windows/desktop/ms681382%28v=vs.85%29.aspx>
391 ERROR_ENVVAR_NOT_FOUND = 203
392
393 def windows_getenv(name):
394     # Based on <http://stackoverflow.com/questions/2608200/problems-with-umlauts-in-python-appdata-environvent-variable/2608368#2608368>,
395     # with improved error handling. Returns None if there is no enivronment variable of the given name.
396     if not isinstance(name, unicode):
397         raise AssertionError("name must be Unicode")
398
399     n = GetEnvironmentVariableW(name, None, 0)
400     # GetEnvironmentVariableW returns DWORD, so n cannot be negative.
401     if n == 0:
402         err = get_last_error()
403         if err == ERROR_ENVVAR_NOT_FOUND:
404             return None
405         raise OSError("WinError: %s\n attempting to read size of environment variable %r"
406                       % (WinError(err), name))
407     if n == 1:
408         # Avoid an ambiguity between a zero-length string and an error in the return value of the
409         # call to GetEnvironmentVariableW below.
410         return u""
411
412     buf = create_unicode_buffer(u'\0'*n)
413     retval = GetEnvironmentVariableW(name, buf, n)
414     if retval == 0:
415         err = get_last_error()
416         if err == ERROR_ENVVAR_NOT_FOUND:
417             return None
418         raise OSError("WinError: %s\n attempting to read environment variable %r"
419                       % (WinError(err), name))
420     if retval >= n:
421         raise OSError("Unexpected result %d (expected less than %d) from GetEnvironmentVariableW attempting to read environment variable %r"
422                       % (retval, n, name))
423
424     return buf.value
425
426 def get_disk_stats(whichdir, reserved_space=0):
427     """Return disk statistics for the storage disk, in the form of a dict
428     with the following fields.
429       total:            total bytes on disk
430       free_for_root:    bytes actually free on disk
431       free_for_nonroot: bytes free for "a non-privileged user" [Unix] or
432                           the current user [Windows]; might take into
433                           account quotas depending on platform
434       used:             bytes used on disk
435       avail:            bytes available excluding reserved space
436     An AttributeError can occur if the OS has no API to get disk information.
437     An EnvironmentError can occur if the OS call fails.
438
439     whichdir is a directory on the filesystem in question -- the
440     answer is about the filesystem, not about the directory, so the
441     directory is used only to specify which filesystem.
442
443     reserved_space is how many bytes to subtract from the answer, so
444     you can pass how many bytes you would like to leave unused on this
445     filesystem as reserved_space.
446     """
447
448     if have_GetDiskFreeSpaceExW:
449         # If this is a Windows system and GetDiskFreeSpaceExW is available, use it.
450         # (This might put up an error dialog unless
451         # SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOOPENFILEERRORBOX) has been called,
452         # which we do in allmydata.windows.fixups.initialize().)
453
454         n_free_for_nonroot = c_ulonglong(0)
455         n_total            = c_ulonglong(0)
456         n_free_for_root    = c_ulonglong(0)
457         retval = GetDiskFreeSpaceExW(whichdir, byref(n_free_for_nonroot),
458                                                byref(n_total),
459                                                byref(n_free_for_root))
460         if retval == 0:
461             raise OSError("WinError: %s\n attempting to get disk statistics for %r"
462                           % (WinError(get_last_error()), whichdir))
463         free_for_nonroot = n_free_for_nonroot.value
464         total            = n_total.value
465         free_for_root    = n_free_for_root.value
466     else:
467         # For Unix-like systems.
468         # <http://docs.python.org/library/os.html#os.statvfs>
469         # <http://opengroup.org/onlinepubs/7990989799/xsh/fstatvfs.html>
470         # <http://opengroup.org/onlinepubs/7990989799/xsh/sysstatvfs.h.html>
471         s = os.statvfs(whichdir)
472
473         # on my mac laptop:
474         #  statvfs(2) is a wrapper around statfs(2).
475         #    statvfs.f_frsize = statfs.f_bsize :
476         #     "minimum unit of allocation" (statvfs)
477         #     "fundamental file system block size" (statfs)
478         #    statvfs.f_bsize = statfs.f_iosize = stat.st_blocks : preferred IO size
479         # on an encrypted home directory ("FileVault"), it gets f_blocks
480         # wrong, and s.f_blocks*s.f_frsize is twice the size of my disk,
481         # but s.f_bavail*s.f_frsize is correct
482
483         total = s.f_frsize * s.f_blocks
484         free_for_root = s.f_frsize * s.f_bfree
485         free_for_nonroot = s.f_frsize * s.f_bavail
486
487     # valid for all platforms:
488     used = total - free_for_root
489     avail = max(free_for_nonroot - reserved_space, 0)
490
491     return { 'total': total,
492              'free_for_root': free_for_root,
493              'free_for_nonroot': free_for_nonroot,
494              'used': used,
495              'avail': avail,
496            }
497
498 def get_available_space(whichdir, reserved_space):
499     """Returns available space for share storage in bytes, or None if no
500     API to get this information is available.
501
502     whichdir is a directory on the filesystem in question -- the
503     answer is about the filesystem, not about the directory, so the
504     directory is used only to specify which filesystem.
505
506     reserved_space is how many bytes to subtract from the answer, so
507     you can pass how many bytes you would like to leave unused on this
508     filesystem as reserved_space.
509     """
510     try:
511         return get_disk_stats(whichdir, reserved_space)['avail']
512     except AttributeError:
513         return None
514     except EnvironmentError:
515         log.msg("OS call to get disk statistics failed")
516         return 0