#!/usr/bin/env python3
"""Cache I/O for keycap-resolve, using open flags the shell cannot reach.
mkdir
create/verify the private cache directory
read write the cache to stdout; exit 0 only if it passed
write publish at
The whole point of this file is the flags on the two open calls.
Reading opens with O_NOFOLLOW|O_NONBLOCK and then decides everything from
fstat on that one descriptor. Checking the pathname first and opening it
afterwards leaves a window in which the name can be swapped for a symlink;
here there is no window, because the kernel refuses the symlink at open time
and every later check is made against the object actually opened.
Writing creates its temporary with O_CREAT|O_EXCL|O_NOFOLLOW, which cannot
land on a name that already exists, and keeps that descriptor open through
write, verification and fsync. The pathname is never reopened. Publication is
os.replace, which swaps the directory entry atomically and replaces a symlink
sitting at the destination rather than following it.
"""
import json
import os
import stat
import sys
MAX_BYTES = 1024 * 1024
REQUIRED_KEYS = ("byApp", "byCmd", "byRoute", "byName")
CLOEXEC = getattr(os, "O_CLOEXEC", 0)
def ensure_dir(path):
"""A directory this user owns, that only this user may enter."""
try:
os.mkdir(path, 0o700)
except FileExistsError:
pass
except OSError:
return 1
try:
st = os.lstat(path) # lstat: a symlink here must fail, not be followed
except OSError:
return 1
if not stat.S_ISDIR(st.st_mode):
return 1
if st.st_uid != os.geteuid():
return 1
if st.st_mode & 0o077:
return 1
return 0
def read_cache(path):
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | CLOEXEC)
except OSError:
# Missing, or a symlink, or a fifo with no writer: all uncacheable.
return 1
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode):
return 1
if st.st_uid != os.geteuid():
return 1
if not 0 < st.st_size <= MAX_BYTES:
return 1
os.set_blocking(fd, True)
chunks = []
total = 0
while total < MAX_BYTES:
try:
chunk = os.read(fd, min(65536, MAX_BYTES - total))
except OSError:
return 1
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
body = b"".join(chunks)
finally:
os.close(fd)
try:
parsed = json.loads(body)
text = body.decode("utf-8")
except (ValueError, UnicodeDecodeError):
return 1
if not isinstance(parsed, dict):
return 1
if any(key not in parsed for key in REQUIRED_KEYS):
return 1
sys.stdout.write(text)
if not text.endswith("\n"):
sys.stdout.write("\n")
return 0
def write_cache(path, source):
try:
with open(source, "rb") as handle:
body = handle.read(MAX_BYTES + 1)
except OSError:
return 1
if not 0 < len(body) <= MAX_BYTES:
return 1
directory = os.path.dirname(path) or "."
fd = None
tmp = None
try:
for _ in range(8):
candidate = os.path.join(
directory, ".keycap.%d.%s" % (os.getpid(), os.urandom(8).hex())
)
try:
fd = os.open(
candidate,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | CLOEXEC,
0o600,
)
tmp = candidate
break
except FileExistsError:
continue
if fd is None:
return 1
view = memoryview(body)
written = 0
while written < len(body):
written += os.write(fd, view[written:])
os.fsync(fd)
if os.fstat(fd).st_size != len(body):
return 1
os.close(fd)
fd = None
os.replace(tmp, path)
tmp = None
return 0
except OSError:
return 1
finally:
if fd is not None:
try:
os.close(fd)
except OSError:
pass
if tmp is not None:
try:
os.unlink(tmp)
except OSError:
pass
def main(argv):
if len(argv) >= 3 and argv[1] == "mkdir":
return ensure_dir(argv[2])
if len(argv) >= 3 and argv[1] == "read":
return read_cache(argv[2])
if len(argv) >= 4 and argv[1] == "write":
return write_cache(argv[2], argv[3])
print(__doc__, file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv))