83 lines
3.0 KiB
Python
Executable File
83 lines
3.0 KiB
Python
Executable File
#!/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=<console account> --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:]))
|