#!/usr/bin/python3 """Read or write a file of the editor of the Router Manager's Shell page, as the user it runs as. The web service runs it with systemd-run --uid= --pipe, outside its sandbox, so the file is read and written with the rights of the shell's user. The version of a content is the hexadecimal SHA-256 of its bytes; a missing file has the version of the empty content. editor-file read PATH print the content of the file; nothing for a missing file editor-file write PATH VERSION replace the content with standard input when the file still has VERSION, writing in place, so its owner, mode and links stay; a missing file is created Only regular files of at most MAX_BYTES bytes of UTF-8 text are read and written. The exit status is 0 on success, 2 for a file that is refused, with the reason on standard error, 3 when the file does not have VERSION any more, and 1 for any other failure. """ import hashlib import os import sys #: The largest file the editor opens, in bytes. MAX_BYTES = 1024 * 1024 class Refused(Exception): """A file the editor does not open or write; status is the exit status.""" def __init__(self, message, status=2): super().__init__(message) self.status = status def text(data, path): """The bytes when they are UTF-8 text of at most MAX_BYTES; Refused otherwise.""" if len(data) > MAX_BYTES: raise Refused(f'{path} is larger than {MAX_BYTES // 1024} KiB') try: data.decode('utf-8') except UnicodeDecodeError: raise Refused(f'{path} is not UTF-8 text') return data def current(path): """The content of the file, b'' when it does not exist; Refused for a file that is not a regular one.""" if not os.path.lexists(path): return b'' if not os.path.isfile(path): raise Refused(f'{path} is not a regular file') with open(path, 'rb') as handle: return text(handle.read(MAX_BYTES + 1), path) def main(argv): if len(argv) < 2 or (argv[0], len(argv)) not in (('read', 2), ('write', 3)): print('usage: editor-file read PATH | editor-file write PATH VERSION', file=sys.stderr) return 2 path = argv[1] try: if argv[0] == 'read': sys.stdout.buffer.write(current(path)) return 0 content = text(sys.stdin.buffer.read(MAX_BYTES + 1), path) if hashlib.sha256(current(path)).hexdigest() != argv[2]: raise Refused(f'{path} changed since it was read', 3) with open(path, 'r+b' if os.path.lexists(path) else 'xb') as handle: handle.write(content) handle.truncate() handle.flush() os.fsync(handle.fileno()) except Refused as refused: print(refused, file=sys.stderr) return refused.status except OSError as exc: print(f'{exc.filename or path}: {exc.strerror}', file=sys.stderr) return 1 return 0 if __name__ == '__main__': sys.exit(main(sys.argv[1:]))