#!/opt/cloudlinux/venv/bin/python3 -bb # coding:utf-8 """Remove one account-owned Bun migration scratch transaction safely.""" import errno import os import re import stat import sys TRANSACTION = re.compile(r'^\.cl-bun-migration-[0-9a-f]{32}$') OPEN_DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW def _remove_tree(directory_fd): """Delete entries by descriptor, refusing to traverse symlinked dirs. One single post-order pass. Re-deriving each directory's path from the transaction root cost one `open` per component, twice per directory -- quadratic in a depth the account chooses. Only the directory being emptied holds a descriptor; a popped frame reaches its parent through `..` and proves it is the inode it descended from. """ def open_directory(dir_fd, name): child_fd = os.open(name, OPEN_DIRECTORY, dir_fd=dir_fd) try: return child_fd, os.fstat(child_fd) except BaseException: os.close(child_fd) raise current_fd = os.dup(directory_fd) root_stat = os.fstat(current_fd) frames = [{'name': None, 'names': None, 'index': 0, 'dev': root_stat.st_dev, 'ino': root_stat.st_ino}] try: while frames: frame = frames[-1] if frame['names'] is None: frame['names'] = os.listdir(current_fd) if frame['index'] >= len(frame['names']): if frame['name'] is None: frames.pop() continue parent_fd, parent_stat = open_directory(current_fd, '..') above = frames[-2] try: if (parent_stat.st_dev != above['dev'] or parent_stat.st_ino != above['ino']): raise OSError(errno.ENOTDIR, 'transaction changed while cleaning') os.rmdir(frame['name'], dir_fd=parent_fd) except BaseException: os.close(parent_fd) raise os.close(current_fd) current_fd = parent_fd frames.pop() continue name = frame['names'][frame['index']] frame['index'] += 1 entry_stat = os.stat(name, dir_fd=current_fd, follow_symlinks=False) if stat.S_ISDIR(entry_stat.st_mode): child_fd, child_stat = open_directory(current_fd, name) os.close(current_fd) current_fd = child_fd frames.append({'name': name, 'names': None, 'index': 0, 'dev': child_stat.st_dev, 'ino': child_stat.st_ino}) else: # A symlink is an entry in the transaction, never a path # to traverse. unlinkat removes the link itself. os.unlink(name, dir_fd=current_fd) finally: os.close(current_fd) def clean(home, transaction): if not TRANSACTION.match(transaction): raise ValueError('invalid migration transaction') home_fd = os.open(home, OPEN_DIRECTORY) try: try: scratch_fd = os.open('.cl-bun-migration', OPEN_DIRECTORY, dir_fd=home_fd) except OSError as exc: if exc.errno == errno.ENOENT: return raise try: try: transaction_fd = os.open(transaction, OPEN_DIRECTORY, dir_fd=scratch_fd) except OSError as exc: if exc.errno == errno.ENOENT: return raise try: _remove_tree(transaction_fd) finally: os.close(transaction_fd) os.rmdir(transaction, dir_fd=scratch_fd) finally: os.close(scratch_fd) try: os.rmdir('.cl-bun-migration', dir_fd=home_fd) except OSError as exc: if exc.errno not in (errno.ENOENT, errno.ENOTEMPTY): raise finally: os.close(home_fd) def main(argv=None): argv = sys.argv[1:] if argv is None else argv if len(argv) != 2: sys.stderr.write('usage: cloudlinux_bun_migration_cleanup HOME TRANSACTION\n') return 2 try: clean(argv[0], argv[1]) except (OSError, ValueError) as exc: sys.stderr.write('migration scratch cleanup refused: %s\n' % exc) return 1 return 0 if __name__ == '__main__': sys.exit(main())