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