debian-forge/osbuild/util/rmrf.py
Christian Kellner 7a923efb1d util/rmrf: handle broken symlinks
The current implementation of `rmtree` will try to fix permissions
when it encounters permission errors during its operation. This is
done by opening the target via `os.open` and then adjusting the
immutable flag and the permission bits. This is a problem when the
target is a broken symlink since open will fail with `ENOENT`. A
simple reproducer of this scenario is:
  $ mkdir subdir
  $ ln -s foo subdir/broken
  $ chmod a-w subdir/
  $ python3 -c 'import osbuild; osbuild.util.rmrf.rmtree("subdir")'

Since subdir is not writable, removing `subdir/broken` will fail
with `EPERM` and the `on_error` callback will try to fix it by
invoking `fixperms` on `subdir/broken` which will in `open` since
the target does not exist (broken symlink).

This is fixed by using `O_NOFOLLOW` to open so we will never open
the target. Instead `open` will fail with `ELOOP`; we ignore that
error and in fact we ignore now all errors from `open` since it
does not matter: if fixing the permissions didn't work `unlink`
will just fail (again) with `EPERM` and for symlinks it actually
doesn't matter since "on Linux the permissions of an ordinary
symbolic link are not used in an operations", see symlinks(7).
2021-12-02 11:38:30 +01:00

110 lines
3.5 KiB
Python

"""Recursive File System Removal
This module implements `rm -rf` as a python function. Its core is the
`rmtree()` function, which takes a file-system path and then recursively
deletes everything it finds on that path, until eventually the path entry
itself is dropped. This is modeled around `shutil.rmtree()`.
This function tries to be as thorough as possible. That is, it tries its best
to modify permission bits and other flags to make sure directory entries can be
removed.
"""
import os
import shutil
import osbuild.util.linux as linux
__all__ = [
"rmtree",
]
def rmtree(path: str):
"""Recursively Remove from File System
This removes the object at the given path from the file-system. It
recursively iterates through its content and removes them, before removing
the object itself.
This function is modeled around `shutil.rmtree()`, but extends its
functionality with a more aggressive approach. It tries much harder to
unlink file system objects. This includes immutable markers and more.
Note that this function can still fail. In particular, missing permissions
can always prevent this function from succeeding. However, a caller should
never assume that they can intentionally prevent this function from
succeeding. In other words, this function might be extended in any way in
the future, to be more powerful and successful in removing file system
objects.
Parameters
---------
path
A file system path pointing to the object to remove.
Raises
------
Exception
This raises the same exceptions as `shutil.rmtree()` (since that
function is used internally). Consult its documentation for details.
"""
def fixperms(p):
fd = None
try:
# if we can't open the file, we just return and let the unlink
# fail (again) with `EPERM`.
# A notable case of why open would fail is symlinks; since we
# want the symlink and not the target we pass the `O_NOFOLLOW`
# flag, but this will result in `ELOOP`, thus we never change
# symlinks. This should be fine though since "on Linux, the
# permissions of an ordinary symbolic link are not used in any
# operations"; see symlinks(7).
try:
fd = os.open(p, os.O_RDONLY | os.O_NOFOLLOW)
except OSError:
return
# The root-only immutable flag prevents files from being unlinked
# or modified. Clear it, so we can unlink the file-system tree.
try:
linux.ioctl_toggle_immutable(fd, False)
except OSError:
pass
# If we do not have sufficient permissions on a directory, we
# cannot traverse it, nor unlink its content. Make sure to set
# sufficient permissions up front.
try:
os.fchmod(fd, 0o777)
except OSError:
pass
finally:
if fd is not None:
os.close(fd)
def unlink(p):
try:
os.unlink(p)
except IsADirectoryError:
rmtree(p)
except FileNotFoundError:
pass
def on_error(_fn, p, exc_info):
e = exc_info[0]
if issubclass(e, FileNotFoundError):
pass
elif issubclass(e, PermissionError):
if p != path:
fixperms(os.path.dirname(p))
fixperms(p)
unlink(p)
else:
raise e
shutil.rmtree(path, onerror=on_error)