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