# file.py -- Safe access to git files
# Copyright (C) 2010 Google, Inc.
#
# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
# General Public License as public by the Free Software Foundation; version 2.0
# or (at your option) any later version. You can redistribute it and/or
# modify it under the terms of either of these two licenses.
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# You should have received a copy of the licenses; if not, see
# for a copy of the GNU General Public License
# and for a copy of the Apache
# License, Version 2.0.
#
"""Safe access to git files."""
import os
import sys
import warnings
def ensure_dir_exists(dirname):
"""Ensure a directory exists, creating if necessary."""
try:
os.makedirs(dirname)
except FileExistsError:
pass
def _fancy_rename(oldname, newname):
"""Rename file with temporary backup file to rollback if rename fails."""
if not os.path.exists(newname):
try:
os.rename(oldname, newname)
except OSError:
raise
return
# Defer the tempfile import since it pulls in a lot of other things.
import tempfile
# destination file exists
try:
(fd, tmpfile) = tempfile.mkstemp(".tmp", prefix=oldname, dir=".")
os.close(fd)
os.remove(tmpfile)
except OSError:
# either file could not be created (e.g. permission problem)
# or could not be deleted (e.g. rude virus scanner)
raise
try:
os.rename(newname, tmpfile)
except OSError:
raise # no rename occurred
try:
os.rename(oldname, newname)
except OSError:
os.rename(tmpfile, newname)
raise
os.remove(tmpfile)
def GitFile(filename, mode="rb", bufsize=-1, mask=0o644):
"""Create a file object that obeys the git file locking protocol.
Returns: a builtin file object or a _GitFile object
Note: See _GitFile for a description of the file locking protocol.
Only read-only and write-only (binary) modes are supported; r+, w+, and a
are not. To read and write from the same file, you can take advantage of
the fact that opening a file for write does not actually open the file you
request.
The default file mask makes any created files user-writable and
world-readable.
"""
if "a" in mode:
raise OSError("append mode not supported for Git files")
if "+" in mode:
raise OSError("read/write mode not supported for Git files")
if "b" not in mode:
raise OSError("text mode not supported for Git files")
if "w" in mode:
return _GitFile(filename, mode, bufsize, mask)
else:
return open(filename, mode, bufsize)
class FileLocked(Exception):
"""File is already locked."""
def __init__(self, filename, lockfilename) -> None:
self.filename = filename
self.lockfilename = lockfilename
super().__init__(filename, lockfilename)
class _GitFile:
"""File that follows the git locking protocol for writes.
All writes to a file foo will be written into foo.lock in the same
directory, and the lockfile will be renamed to overwrite the original file
on close.
Note: You *must* call close() or abort() on a _GitFile for the lock to be
released. Typically this will happen in a finally block.
"""
PROXY_PROPERTIES = {
"closed",
"encoding",
"errors",
"mode",
"name",
"newlines",
"softspace",
}
PROXY_METHODS = (
"__iter__",
"flush",
"fileno",
"isatty",
"read",
"readline",
"readlines",
"seek",
"tell",
"truncate",
"write",
"writelines",
)
def __init__(self, filename, mode, bufsize, mask) -> None:
self._filename = filename
if isinstance(self._filename, bytes):
self._lockfilename = self._filename + b".lock"
else:
self._lockfilename = self._filename + ".lock"
try:
fd = os.open(
self._lockfilename,
os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
mask,
)
except FileExistsError as exc:
raise FileLocked(filename, self._lockfilename) from exc
self._file = os.fdopen(fd, mode, bufsize)
self._closed = False
for method in self.PROXY_METHODS:
setattr(self, method, getattr(self._file, method))
def abort(self):
"""Close and discard the lockfile without overwriting the target.
If the file is already closed, this is a no-op.
"""
if self._closed:
return
self._file.close()
try:
os.remove(self._lockfilename)
self._closed = True
except FileNotFoundError:
# The file may have been removed already, which is ok.
self._closed = True
def close(self):
"""Close this file, saving the lockfile over the original.
Note: If this method fails, it will attempt to delete the lockfile.
However, it is not guaranteed to do so (e.g. if a filesystem
becomes suddenly read-only), which will prevent future writes to
this file until the lockfile is removed manually.
Raises:
OSError: if the original file could not be overwritten. The
lock file is still closed, so further attempts to write to the same
file object will raise ValueError.
"""
if self._closed:
return
self._file.flush()
os.fsync(self._file.fileno())
self._file.close()
try:
if getattr(os, "replace", None) is not None:
os.replace(self._lockfilename, self._filename)
else:
if sys.platform != "win32":
os.rename(self._lockfilename, self._filename)
else:
# Windows versions prior to Vista don't support atomic
# renames
_fancy_rename(self._lockfilename, self._filename)
finally:
self.abort()
def __del__(self) -> None:
if not getattr(self, '_closed', True):
warnings.warn('unclosed %r' % self, ResourceWarning, stacklevel=2)
self.abort()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
self.abort()
else:
self.close()
def __getattr__(self, name):
"""Proxy property calls to the underlying file."""
if name in self.PROXY_PROPERTIES:
return getattr(self._file, name)
raise AttributeError(name)