#!/usr/bin/python3

#
# jemf: an encrypted mini-filesystem.
#
# (command-line password manager)
#
# Copyright (c) Zev Weiss <zev@bewilderbeest.net>
#

import sys
import argparse
from getpass import getuser as lib_getuser, getpass as lib_getpass
import subprocess
import json
import os
import os.path
import errno
import secrets
import socket
import time
import signal
import curses
import tempfile
from cmd import Cmd as CommandInterpreter
import shlex
import types
import functools
import base64
import hashlib
import atexit
import re
import struct
import traceback

from typing import NoReturn, Any, Optional, List, Dict, Tuple, Callable, Union
from typing import Text, Sequence, ItemsView, cast
from types import FrameType

try:
	import readline
	import string
	readline.set_completer_delims(string.whitespace + os.sep)
except ImportError:
	pass

default_fspaths = [os.path.expanduser(p) for p in ["~/.config/jemf", "~/.jemf"]]
gpgcmd = "gpg2"

CURRENT_FORMAT_VERSION = 3

# python3's os.pipe() sets O_CLOEXEC, which we don't want
def pipe() -> Tuple[int, int]:
	return os.pipe2(0)

class UserError(Exception):
	pass

class UsageError(UserError):
	pass

class InternalError(Exception):
	pass

class ProtocolError(Exception):
	pass

class CorruptFS(Exception):
	pass

class FormatVersionMismatch(Exception):
	pass

class PathLookupError(UserError):
	pass

class FileNotFound(PathLookupError):
	pass

class NotADirectory(PathLookupError):
	pass

def errprint_tty(msg: str) -> None:
	if msg != '' and msg[-1] != '\n':
		msg += '\n'
	print(msg, end='', file=sys.stderr)

zenity_args = ["zenity", "--title", "jemf"]

def errprint_gui(msg: str) -> None:
	errprogs = [zenity_args + ["--error", "--text"], ["gxmessage"], ["xmessage"]]
	for ep in errprogs:
		try:
			subprocess.call(ep + [msg])
			return
		except:
			pass
	print("(None of %s succeeded)" % [ep[0] for ep in errprogs], file=sys.stderr)
	errprint_tty(msg)

def error(msg: str) -> NoReturn:
	raise UserError("Error: %s" % msg)

def internal_error(msg: str) -> NoReturn:
	raise InternalError("Internal error: %s" % msg)

def getpass_gui(prompt: str) -> str:
	progs = [["ssh-askpass"], ["gnome-ssh-askpass"],
		 zenity_args + ["--entry", "--hide-text", "--text"]]
	for p in progs:
		try:
			proc = subprocess.Popen(p + [prompt], stdout=subprocess.PIPE)
		except OSError as ose:
			continue
		output = proc.communicate()[0].decode("utf-8")
		if proc.wait() == 0:
			assert output[-1] == '\n'
			return output[:-1]

	error("None of %s succeeded" % [p[0] for p in progs])

def getpass_tty(prompt: str) -> str:
	return lib_getpass(prompt)

getpass_interactive = getpass_tty
errprint = errprint_tty

def report_internal_error(err: InternalError) -> None:
	errprint("Internal error!  Please report a bug with the following stack trace:")
	errprint(''.join(traceback.format_exception(err)))

def confirm_gui(prompt: str) -> bool:
	xmsg_flags = ["-buttons", "No:1,Yes:0", "-default", "Yes"]
	progs = [zenity_args + ["--question", "--text"],
		 ["gxmessage"] + xmsg_flags,
		 ["xmessage"] + xmsg_flags]
	for p in progs:
		try:
			ret = subprocess.call(p + [prompt])
			return ret == 0
		except:
			pass
	error("None of %s succeeded" % [p[0] for p in progs])

def getpass(prompt: str) -> str:
	if os.getenv("__JEMF_TEST__") == '1':
		tmp = os.getenv("__JEMF_TEST_PASSWORD__")
		assert tmp is not None
		return tmp
	return getpass_interactive(prompt)

def confirmed_getpass(label: str) -> str:
	pass1 = getpass("Enter %s: " % label)
	pass2 = getpass("Confirm %s: " % label)
	if pass1 != pass2:
		error("input mismatch")
	else:
		return pass1

def find_fs() -> Optional[str]:
	for p in default_fspaths:
		if os.path.exists(p):
			return p
	return None

class JemfJSONEncoder(json.JSONEncoder):
	def default(self, obj: Any) -> Any:
		if isinstance(obj, FSObj):
			return obj.to_json_obj()
		else:
			return json.JSONEncoder.default(self, obj)

def pretty_json_dump(d: Any) -> str:
	return json.dumps(d, sort_keys=True, indent=4, separators=(',', ': '), cls=JemfJSONEncoder)

def check_tty_print_interlock(force: bool) -> None:
	if not force and sys.stdout.isatty():
		error("won't print to terminal without '-f'")

def run_gpg(args: List[str], passwd: str, stdin_data: Optional[bytes]=None) -> bytes:
	pw_rfd, pw_wfd = pipe()
	pw_w = os.fdopen(pw_wfd, "w")
	cmdargs = [gpgcmd, "--quiet", "--batch", "--passphrase-fd", str(pw_rfd)] + args

	try:
		# close write end of passphrase pipe in child process
		proc = subprocess.Popen(cmdargs, stdout=subprocess.PIPE, stdin=subprocess.PIPE,
					stderr=subprocess.PIPE, close_fds=False,
					preexec_fn=pw_w.close)
	except OSError as ose:
		if ose.errno == errno.ENOENT:
			raise UserError("Error attempting to execute %s: %s"
					% (gpgcmd, ose.strerror))
		else:
			raise

	# parent doesn't use read end
	os.close(pw_rfd)

	# send passphrase to gpg child
	pw_w.write(passwd)
	# probably redundant, since we're just about to close it, but why not...
	pw_w.flush()
	pw_w.close()

	stdout_data, stderr_data = proc.communicate(stdin_data)

	retval = proc.wait()
	if retval != 0:
		msg = stderr_data.decode("utf-8")
		# make a common message from gpg a bit more "user friendly"
		if "decryption failed: Bad session key" in msg:
			msg = "Error: incorrect password."
		errprint(msg)
		raise subprocess.CalledProcessError(retval, cmdargs)

	return stdout_data

def gpg_decrypt(path: str, password: str) -> bytes:
	return run_gpg(["-d", "--no-mdc-warning", path], password)

def gpg_encrypt(path: str, password: str, data: bytes) -> bytes:
	return run_gpg(["-c", "--yes", "-o", path], password, data)

def update_fsfile(path: str, password: str, plaintext: bytes) -> None:
	# If filename is a symlink, do the new file write & rename in
	# the directory in which the pointed-to file resides
	filename = os.path.realpath(path)

	dirpath = os.path.dirname(filename)
	tmpfd, tmppath = tempfile.mkstemp(prefix=os.path.basename(filename), dir=dirpath)

	output = gpg_encrypt(tmppath, password, plaintext)
	if output != b'':
		os.unlink(tmppath)
		internal_error("Unexpected output from gpg: %r\n" % output)

	os.fsync(tmpfd)
	os.close(tmpfd)

	os.rename(tmppath, path)

	# fsync the containing directory to persist the rename
	dirfd = os.open(dirpath, os.O_RDONLY)
	os.fsync(dirfd)
	os.close(dirfd)

hostname = None
def get_hostname() -> str:
	global hostname
	if hostname is None:
		hostname = socket.gethostbyaddr(socket.gethostname())[0]
	return hostname

multi_slash_pat = re.compile('//+')
trailing_slash_pat = re.compile('/+$')

def path_components(path: str) -> List[str]:
	# collapse redundant slashes and remove trailing slashes
	np = re.sub(multi_slash_pat, '/', path)
	np = re.sub(trailing_slash_pat, '', np)
	while len(np) > 0 and np[0] == os.sep:
		np = np[1:]
	return np.split(os.sep) if len(np) > 0 else []

default_constraints = "L12:C2:m2:N2:P2"

gen_constraints_desc = """
CONSTRAINTS is a colon-separated list of data constraints of the form TV.

T is a type indicator: L for length, C for capital letters, m for
lower-case letters, N for numerals (digits), and P for punctuation
characters.

V is the numeric value of of the constraint: for L (length) this
specifies the length of the generated data; for all others it provides
a minimum number of characters of that type, though as a special case
V=0 means the character type will be omitted.

The default constraint is %s, specifying a twelve-character
entry with at least two capital letters, at least two lower-case
letters, at least two digits, and at least two punctuation characters.
The user-provided constraint list (which may be empty) can selectively
override these constraints.

Stupid-site example: L8:C2:m2:N2:P0 for an 8-character purely
alphanumeric (but mixed-case) entry.""" % default_constraints

char_classes = {
	'C': "ABCDEFGHIJKLMNOPQRSTUVWXYZ", # uppercase
	'm': "abcdefghijklmnopqrstuvwxyz", # lowercase
	'N': "0123456789", # digits
	'P': "!@#$%^&*:._-=+", # punctuation
}

def constraints_to_dict(cstr: str) -> Dict[str, int]:
	d = {}
	for c in cstr.split(":"):
		if c != "":
			if c[0] not in char_classes and c[0] != 'L':
				error("invalid constraint type: '%c'" % c[0])
			d[c[0]] = int(c[1:])
	return d

def generate_data(constraints: str, punctuation_chars: Optional[str]=None) -> str:
	classes = char_classes.copy()
	if punctuation_chars is not None:
		classes['P'] = punctuation_chars

	cdict = constraints_to_dict(default_constraints)
	cdict.update(constraints_to_dict(constraints))
	s, padchars = "", ""
	for c, chars in classes.items():
		for _ in range(0, cdict[c]):
			s += secrets.choice(chars)
		if cdict[c] > 0:
			padchars += chars

	# fill remaining space from pad character sets
	for _ in range(0, cdict['L']-len(s)):
		s += secrets.choice(padchars)

	l = list(s)
	secrets.SystemRandom().shuffle(l)
	return ''.join(l[:cdict['L']])

def isdot(p: str) -> bool:
	return p.startswith('.') and p not in [".", ".."]

def recursive_list(obj: 'FSObj', pfx: str, fileonly: bool=False, skip_dots: bool=True) -> List[str]:
	l = []
	skip = skip_dots and isdot(os.path.basename(pfx))
	if (not fileonly or isinstance(obj, File)) and not skip:
		l.append(pfx)
	if isinstance(obj, Directory) and not skip:
		for n, o in obj.items():
			l += recursive_list(o, os.path.join(pfx, n), fileonly=fileonly,
					    skip_dots=skip_dots)
	return l

def extract(jd: Dict[str, Any], key: str, expected: type) -> Any:
	if key not in jd:
		raise CorruptFS("missing %s" % key)
	tmp = jd.pop(key)
	if not isinstance(tmp, expected):
		raise CorruptFS("invalid %s (%s)" % (key, type(tmp).__name__))
	return tmp

class FSObj(object):
	# to be overridden with something meaningful by derived bclasses
	type_id: Optional[str] = None

	def __init__(self, init_metadata: bool=True) -> None:
		self.mtime: Optional[float] = None
		self.mtzname: Optional[str] = None
		self.mhost: Optional[str] = None
		if init_metadata:
			self.touch()

	def touch(self) -> None:
		self.mtime = time.time()
		self.mtzname = time.tzname[time.daylight]
		self.mhost = get_hostname()

	def _fill_from_json_obj(self, jd: Dict[str, Any]) -> None:
		self.mtime = extract(jd, "mtime", float)
		self.mtzname = extract(jd, "mtzname", str)
		self.mhost = extract(jd, "mhost", str)

	def to_json_obj(self) -> Dict[str, Any]:
		return dict(mtime=self.mtime, mtzname=self.mtzname, mhost=self.mhost,
			    type=self.type_id)

class File(FSObj):
	type_id = 'f'

	def __init__(self, data: Optional[str], init_metadata: bool=True) -> None:
		super().__init__(init_metadata)
		self.data = data

	def _fill_from_json_obj(self, jd: Dict[str, Any]) -> None:
		super()._fill_from_json_obj(jd)
		self.data = extract(jd, "data", str)
		if len(jd) > 0:
			raise CorruptFS("extra keys in file object: %s"
					% ", ".join(jd.keys()))

	@classmethod
	def from_json_obj(cls, jd: Dict[str, Any]) -> 'File':
		if not isinstance(jd, dict):
			raise CorruptFS("invalid file object (%s)" % type(jd).__name__)

		f = File(None, init_metadata=False)
		f._fill_from_json_obj(jd)
		return f

	def to_json_obj(self) -> Dict[str, Any]:
		d = super().to_json_obj()
		d["data"] = self.data
		return d

class Directory(FSObj):
	type_id = 'd'

	def __init__(self, parent: Optional['Directory'], init_metadata: bool=True) -> None:
		super().__init__(init_metadata)
		if parent is None:
			parent = self
		else:
			assert type(parent) is Directory
		self._specials = {os.path.curdir: self, os.path.pardir: parent}
		self.dentries: Dict[str, FSObj] = {}

	def _fill_from_json_obj(self, jd: Dict[str, Any]) -> None:
		for name, item in extract(jd, "entries", dict).items():
			t = extract(item, "type", str)
			obj: FSObj
			if t == 'f':
				obj = File.from_json_obj(item)
			elif t == 'd':
				obj = Directory.from_json_obj(item, parent=self)
			elif t == 'l':
				obj = Symlink.from_json_obj(item)
			else:
				raise CorruptFS("invalid object type: %s" % t)
			self[name] = obj

		# do this last so as to clobber metadata updates done
		# automatically while adding dirents
		super()._fill_from_json_obj(jd)

		if len(jd) > 0:
			raise CorruptFS("extra keys in directory object: %s"
					% ", ".join(jd.keys()))

	@classmethod
	def from_json_obj(cls, jd: Dict[str, Any], parent: Optional['Directory']=None) -> 'Directory':
		if not isinstance(jd, dict):
			raise CorruptFS("invalid directory object (%s)" % type(jd).__name__)

		d = Directory(parent, init_metadata=False)
		d._fill_from_json_obj(jd)
		return d

	def to_json_obj(self) -> Dict[str, Any]:
		d = super().to_json_obj()
		d["entries"] = self.dentries
		return d

	def __getitem__(self, k: str) -> FSObj:
		if k in self._specials:
			return self._specials[k]
		else:
			return self.dentries.__getitem__(k)

	def __contains__(self, k: str) -> bool:
		return k in self._specials or self.dentries.__contains__(k)

	def __setitem__(self, k: str, v: FSObj) -> None:
		assert k not in self._specials
		self.touch()
		self.dentries.__setitem__(k, v)

	def __delitem__(self, k: str) -> None:
		assert k not in self._specials
		self.touch()
		self.dentries.__delitem__(k)

	def set_parent(self, p: 'Directory') -> None:
		self._specials[os.path.pardir] = p

	def items(self) -> ItemsView[str, FSObj]:
		return self.dentries.items()

class Symlink(FSObj):
	type_id = 'l'

	def __init__(self, target: Optional[str], init_metadata: bool=True) -> None:
		super().__init__(init_metadata)
		self.target = target

	def _fill_from_json_obj(self, jd: Dict[str, Any]) -> None:
		super()._fill_from_json_obj(jd)
		self.target = extract(jd, "target", str)
		if len(jd) > 0:
			raise CorruptFS("extra keys in symlink object: %s"
					% ", ".join(jd.keys()))

	@classmethod
	def from_json_obj(cls, jd: Dict[str, Any]) -> 'Symlink':
		if not isinstance(jd, dict):
			raise CorruptFS("invalid symlink object (%s)" % type(jd).__name__)

		l = Symlink(None, init_metadata=False)
		l._fill_from_json_obj(jd)
		return l

	def to_json_obj(self) -> Dict[str, Any]:
		d = super().to_json_obj()
		d["target"] = self.target
		return d

def is_nonempty_dir(obj: FSObj) -> bool:
	return isinstance(obj, Directory) and len(obj.dentries) > 0

ansi_red = "\x1b[31m"
ansi_rst = "\x1b[m"

def clear_terminal() -> None:
	curses.setupterm()
	e3 = curses.tigetstr("E3")
	if e3:
		curses.putp(e3)
	clr = curses.tigetstr("clear")
	if clr:
		curses.putp(clr)
	sys.stdout.flush()

Args = argparse.Namespace

class JemfShell(CommandInterpreter):

	# Time out after 3 minutes waiting for input (can be
	# overridden via JEMF_TMOUT environment variable)
	input_timeout = 180

	def __init__(self, jemf: 'Jemf', args: Args):
		self.fs = jemf
		self.args = args
		self.setprompt()
		tmout = os.getenv("JEMF_TMOUT")
		if tmout:
			try:
				self.input_timeout = int(tmout)
			except ValueError:
				errprint("JEMF_TMOUT value not a valid base-10 integer, retaining default (%d)"
					 % self.input_timeout)
		CommandInterpreter.__init__(self)

	def setprompt(self) -> None:
		if sys.stdin.isatty():
			ro = "[ro]" if self.fs.read_only else ""
			try:
				cwd = Jemf.get_dirname(self.fs.cwd)
			except InternalError as ex:
				report_internal_error(ex)
				cwd = '???'
			self.prompt = "%sjemf[%s:%s]%s> %s" % (ansi_red, self.fs.filename, cwd, ro, ansi_rst)
		else:
			self.prompt = ""

	def do_EOF(self, args: List[str]) -> bool:
		"""EOF (^D): exit shell session."""
		if sys.stdin.isatty():
			sys.stdout.write('\n')
		return True

	def set_input_timeout_alarm(self) -> None:
		if self.input_timeout <= 0:
			return
		def alrm_handler(signum: int, frame: Optional[FrameType]) -> None:
			clear_terminal()
			errprint("Input timeout exceeded; exiting.\n")
			sys.exit(1)
		signal.signal(signal.SIGALRM, alrm_handler)
		signal.alarm(self.input_timeout)

	def preloop(self) -> None:
		self.set_input_timeout_alarm()

	def postcmd(self, stop: bool, line: str) -> bool:
		if not stop:
			self.setprompt()
			self.set_input_timeout_alarm()
		return stop

	# This is defined to override default "repeat previous
	# command" behavior on getting an empty line.
	def emptyline(self) -> bool:
		return False

	def default(self, line: str) -> None:
		errmsg = "Unrecognized command: %s" % shlex.split(line)[0]
		if self.args.exit_on_failure:
			raise UserError(errmsg)
		else:
			errprint("%s\n(see 'help')" % errmsg)

	def completedefault(self, orig_text: str, line: str, beg: int, end: int) -> List[str]: # type: ignore[override]
		try:
			if shlex.split(line[:end] + ".")[-1] != ".":
				text = shlex.split(line[:end])[-1]
			else:
				text = orig_text
		except ValueError:
			text = orig_text
		ppath, pfx = os.path.split(text)

		try:
			pdir = self.fs.lookup_path(ppath)
		except PathLookupError:
			return []

		if not isinstance(pdir, Directory):
			return []

		base = orig_text[:-len(pfx)]
		l = []
		for name in pdir.dentries:
			if name.startswith(pfx):
				suffix = os.sep if isinstance(pdir.dentries[name], Directory) else " "
				l.append(base + name + suffix)

		return l

	@classmethod
	def _add_cmd_handler(cls, cmd: 'JemfCommand') -> None:
		def cmdfun(self: 'JemfShell', argstr: str) -> None:
			try:
				args = cmd.parser.parse_args(shlex.split(argstr))
			except (ValueError, UsageError) as exc:
				if exc.args[0]:
					errprint(exc.args[0])
				return
			except HelpFlagPassed:
				return
			# merge in original self.args if not overridden
			for k,v in self.args.__dict__.items():
				if k not in args:
					setattr(args, k, v)
			try:
				cmd(self.fs, args)
			except (InternalError, UserError) as err:
				if self.args.exit_on_failure:
					raise
				if isinstance(err, UserError):
					if err.args[0]:
						errprint(err.args[0])
				elif isinstance(err, InternalError):
					report_internal_error(err)
		cmdfun.__doc__ = cmd.parser.format_help()
		setattr(cls, "do_%s" % cmd.name, cmdfun)


# For throwing when a -h/--help flag is passed, so that a caller N levels up can
# either skip executing the command (in shell mode) or just exit cleanly (in
# batch mode).
class HelpFlagPassed(Exception):
	pass

# Custom help action that doesn't unconditionally exit (which we don't want in
# shell mode).
class JemfHelpAction(argparse.Action):
	def __call__(self, parser: argparse.ArgumentParser, namespace: Args,
		     values: Union[Text, Sequence[Any], None], option_string: Optional[str]=None) -> NoReturn:
		parser.print_help()
		print("") # add an extra line so 'foo --help' output matches 'help foo' (cmd.do_help() adds one)
		raise HelpFlagPassed()

def mk_raise_usage_error(usage: str) -> Callable[[str], NoReturn]:
	usg = usage.strip()
	def func(msg: str) -> NoReturn:
		raise UsageError("%s\n%s" % (usg, msg.strip()))
	return func

def mk_path_arg(desc: str, opt: bool=False, multiple_ok: bool=False) -> Tuple[Tuple[str], Dict[str, str]]:
	d = dict(metavar="PATH", help=desc)
	if opt and multiple_ok:
		d["nargs"] = '*'
	elif multiple_ok:
		d["nargs"] = '+'
	elif opt:
		d["nargs"] = '?'
	return (("path%s" % ("s" if multiple_ok else ""),), d)

JemfCommandFn = Callable[['Jemf', Args], None]
JemfCommandArgs = List[Tuple[Union[Tuple[str], Tuple[str, str]], Dict[str, Any]]]
JemfCommandDecorator = Callable[[JemfCommandFn], 'JemfCommand']

class JemfCommand(object):
	_all_commands: List['JemfCommand'] = []

	def __init__(self, cmdfn: JemfCommandFn, cmd_args: Optional[JemfCommandArgs]=None,
		     parser_kwargs: Optional[Dict[str, Any]]=None, read_only: bool=False,
		     shell_only: bool=False, batch_only: bool=False) -> None:
		self.name = cmdfn.__name__
		self.desc = cmdfn.__doc__
		self.cmdfn = cmdfn
		self.cmd_args = cmd_args if cmd_args is not None else []
		self.parser_kwargs = parser_kwargs if parser_kwargs is not None else {}
		self.read_only = read_only
		self.shell_only = shell_only
		self.batch_only = batch_only
		JemfCommand._all_commands.append(self)

	def __call__(self, origself: 'Jemf', args: Args) -> None:
		return self.cmdfn(origself, args)

	def add_to(self, subparsers: 'argparse._SubParsersAction[argparse.ArgumentParser]') -> None:
		self.parser = subparsers.add_parser(self.name, description=self.desc,
						    help=self.desc, add_help=False, **self.parser_kwargs)
		self.parser.set_defaults(action=self)
		for a in self.cmd_args:
			self.parser.add_argument(*a[0], **a[1])

		# Custom help argument that doesn't unconditionally exit
		# (so that we can avoid exiting when --help is passed to
		# a command in shell mode).
		self.parser.add_argument('-h', "--help", action=JemfHelpAction, default=argparse.SUPPRESS,
					 nargs=0, help="show this help message")

	def create_parser(self) -> None:
		tmp = argparse.ArgumentParser().add_subparsers()
		self.add_to(tmp)

	# decorator for commands that modify the FS
	@staticmethod
	def writer(cmd_args: JemfCommandArgs, **kwargs: Any) -> JemfCommandDecorator:
		def wr_dec(fn: JemfCommandFn) -> JemfCommand:
			@functools.wraps(fn)
			def new_fn(self: 'Jemf', args: Args) -> None:
				# attempts to execute @writer commands should be
				# caught and rejected well before reaching this point
				assert not self.read_only, "@writer command executed on read-only FS"
				fn(self, args)
				self._write_out()
			return JemfCommand(new_fn, cmd_args=cmd_args, read_only=False, **kwargs)
		return wr_dec

	# decorator for read-only commands
	@staticmethod
	def reader(cmd_args: JemfCommandArgs, **kwargs: Any) -> JemfCommandDecorator:
		def rd_dec(fn: JemfCommandFn) -> JemfCommand:
			return JemfCommand(fn, cmd_args=cmd_args, read_only=True, **kwargs)
		return rd_dec

SOCKBUF_SIZE = 1024

class Jemf(object):
	reader = JemfCommand.reader
	writer = JemfCommand.writer
	commands = JemfCommand._all_commands

	def __init__(self, filename: str, password: Optional[str], read_only: bool) -> None:
		self.filename = filename
		self.password = password
		self.read_only = read_only
		self.pipe_to: Optional[str] = None
		self.data = Directory(None)
		self.metadata: Dict[str, Any] = {}
		self.cwd = self.data
		self.socket: Optional[socket.socket] = None
		self.shell_mode = False

	def _to_json(self) -> str:
		d: Dict[str, Any] = {}
		d["data"] = self.data
		d["metadata"] = self.metadata
		return pretty_json_dump(d)

	def _from_json(self, s: str) -> None:
		jd = json.loads(s)
		self.metadata = extract(jd, "metadata", dict)

		fileversion = self.metadata.get("format_version", 1)
		if fileversion < CURRENT_FORMAT_VERSION:
			raise FormatVersionMismatch("data is in v%d format, we need v%d"
						    % (fileversion, CURRENT_FORMAT_VERSION))

		data = extract(jd, "data", dict)
		t = extract(data, "type", str)
		if t != 'd':
			raise CorruptFS("root entry must be a directory, got '%s'" % t)
		self.data = Directory.from_json_obj(data)

		if len(jd):
			raise CorruptFS("extra keys in top level: %s"
					% ", ".join(jd.keys()))

		self.cwd = self.data

	@classmethod
	def load_from_path(cls, filename: str, password: str, read_only: bool) -> 'Jemf':
		fs = cls(filename, password, read_only)
		s = gpg_decrypt(filename, password)
		try:
			fs._from_json(s.decode("utf-8"))
		except KeyError:
			raise CorruptFS("data/metadata not found")
		return fs

	# return a (success-boolean, data) tuple
	def recv_response(self) -> Tuple[bool, Optional[str]]:
		buf = b''
		assert self.socket is not None
		while len(buf) == 0 or buf[-1] != b'\n'[0]:
			t = self.socket.recv(SOCKBUF_SIZE)
			if len(t) == 0:
				raise ProtocolError("invalid response from server")
			else:
				buf += t
		parts = buf[:-1].decode("utf-8").split(' ', 1)
		data = None if len(parts) == 1 else parts[1]
		if parts[0] == "OK":
			status = True
		elif parts[0] == "ERROR":
			status = False
		else:
			raise ProtocolError("invalid response from server: %r" % buf)
		return (status, data)

	@classmethod
	def from_server(cls, socket: socket.socket, filename: str, read_only: bool) -> 'Jemf':
		fs = cls(filename, None, read_only)
		fs.socket = socket
		fs.socket.sendall(b"%s %s\n" % (READ_REQ, base64.b64encode(filename.encode("utf-8"))))
		ok, data = fs.recv_response()
		if ok:
			assert data is not None
			fs._from_json(base64.b64decode(data).decode("utf-8"))
			return fs
		else:
			raise ProtocolError(data)

	def output_secret(self, s: str, force: bool) -> None:
		if self.pipe_to is None:
			check_tty_print_interlock(force)
			print(s, end='\n' if sys.stdout.isatty() else '')
		else:
			proc = subprocess.Popen(self.pipe_to, shell=True, stdin=subprocess.PIPE)
			proc.communicate(s.encode("utf-8"))
			status = proc.wait()
			if status != 0:
				errprint("['%s' exited with status %d]" % (self.pipe_to, status))
			else:
				# might stderr be more appropriate here?
				print("[sent output to '%s']" % self.pipe_to)

	def _write_out(self) -> None:
		self.metadata["last_modification_time"] = time.time()
		self.metadata["last_modification_tzname"] = time.tzname[time.daylight]
		self.metadata["last_modification_host"] = get_hostname()
		jemf_buf = self._to_json().encode("utf-8") + b'\n'

		if self.socket is not None:
			assert self.password is None
			self.socket.sendall(b"%s %s %s\n" % (WRITE_REQ,
							     base64.b64encode(self.filename.encode("utf-8")),
							     base64.b64encode(jemf_buf)))
			ok, data = self.recv_response()
			if ok:
				return
			else:
				raise ProtocolError(data)
		else:
			assert self.password is not None
			update_fsfile(self.filename, self.password, jemf_buf)

	def _lookup_comps(self, root: Directory, comps: List[str]) -> Union[File, Directory]:
		fsdir: FSObj = root
		trail = os.sep
		for c in comps:
			if not isinstance(fsdir, Directory):
				raise NotADirectory("'%s' is not a directory" % trail)
			prevdir = fsdir
			try:
				fsdir = fsdir[c]
			except KeyError:
				raise FileNotFound("'%s' does not exist in '%s'" % (c, trail))
			# TODO: ELOOP detection?
			if isinstance(fsdir, Symlink):
				assert fsdir.target is not None # mypy
				fsdir = self.lookup_path(fsdir.target, override_cwd=prevdir)
			trail = os.path.join(trail, c)
		assert isinstance(fsdir, (File, Directory))
		return fsdir

	def lookup_path(self, path: str, override_cwd: Optional[Directory]=None) -> Union[File, Directory]:
		if len(path) > 0 and path[0] == os.sep:
			root = self.data
		else:
			root = override_cwd if override_cwd else self.cwd
		return self._lookup_comps(root, path_components(path))

	def lookup_for_edit(self, path: str, should_exist: Optional[bool], override_cwd: Optional[Directory]=None) -> Tuple[Directory, str, Optional[FSObj]]:
		"""Lookup 'path', returning a 3-tuple of the parent directory, the
		last component of 'path', and the object itself (or None
		if it doesn't exist).
		"""
		if len(path) > 0 and path[0] == os.sep:
			root = self.data
		else:
			root = override_cwd if override_cwd else self.cwd
		comps = path_components(path)

		if len(comps) > 0:
			parent = self._lookup_comps(root, comps[:-1])
			tgtcomp = comps[-1]
		else:
			parent = root
			tgtcomp = os.path.curdir

		if not isinstance(parent, Directory):
			error("'%s' is not a directory" % os.path.join(*comps))

		if should_exist is not None:
			if not should_exist and tgtcomp in parent:
				error("'%s' already exists" % path)
			elif should_exist and tgtcomp not in parent:
				error("'%s' does not exist" % path)

		existing = parent[tgtcomp] if tgtcomp in parent else None

		return (parent, tgtcomp, existing)

	# dumb brute force...could introduce some extra machinery to make this
	# more efficient, but it's probably good enough.
	@staticmethod
	def get_dirname(obj: Directory) -> str:
		parent = obj[os.path.pardir]
		assert isinstance(parent, Directory)
		if parent is obj:
			return os.sep
		for k, v in parent.items():
			if v is obj:
				return os.path.join(Jemf.get_dirname(parent), k)
		internal_error("unable to determine directory path")

	# some common flags
	generate_arg = (("-g", "--generate"), dict(metavar="CONSTRAINTS", type=str,
						   help="auto-generate entry data"))
	data_arg = (("-D", "--data"), dict(metavar="DATA", type=str,
					   help="use argument as data instead of prompting"))
	punctchars_arg = (("-P", "--punctuation-chars"), dict(metavar="CHARS", type=str,
							      help="specify punctuation characters"
							      " allowed in generated data"))
	force_print_arg = (("-f", "--force"), dict(help="disable anti-tty-print interlock",
						   action="store_true"))
	force_overwrite_arg = (("-f", "--force"), dict(help="overwrite an existing file",
						       action="store_true"))

	@writer([force_overwrite_arg], batch_only=True)
	def mkfs(self, args: Args) -> None:
		"""initialize a new jemf FS"""
		self.data = Directory(None)
		self.metadata = { "format_version": CURRENT_FORMAT_VERSION }

	@writer([])
	def chpass(self, args: Args) -> None:
		"""change the master password of a jemf FS"""

		if self.password is None:
			error("can't change password via background-process socket")

		# Only verify that the user knows the current password
		# in shell mode (in batch mode they'll have just
		# entered it).
		if self.shell_mode:
			if getpass("Enter current password for %s: " % self.filename) != self.password:
				error("password incorrect")

		newpass = confirmed_getpass("new password for %s" % self.filename)
		self.password = newpass

	@reader([mk_path_arg("directory to change to (default: root)", opt=True)],
		shell_only=True)
	def cd(self, args: Args) -> None:
		"""change working directory"""
		if args.path is not None:
			obj = self.lookup_path(args.path)
			if not isinstance(obj, Directory):
				error("'%s' is not a directory" % args.path)
		else:
			obj = self.data
		self.cwd = obj

	@reader([], shell_only=True)
	def pwd(self, args: Args) -> None:
		"""print working directory"""
		print(Jemf.get_dirname(self.cwd))

	@writer([mk_path_arg("the directory to create")])
	def mkdir(self, args: Args) -> None:
		"""create a new directory"""
		parent, newname, _ = self.lookup_for_edit(args.path, should_exist=False)
		d = Directory(parent)
		parent[newname] = d

	@writer([generate_arg, data_arg, punctchars_arg, mk_path_arg("the file to create")],
		parser_kwargs=dict(epilog=gen_constraints_desc,
				   formatter_class=argparse.RawDescriptionHelpFormatter))
	def create(self, args: Args) -> None:
		"""create a new file"""
		parent, newname, existing = self.lookup_for_edit(args.path, should_exist=None)
		if existing is not None:
			if not isinstance(existing, Symlink):
				error("'%s' already exists" % args.path)

			assert existing.target is not None # mypy

			# create on an existing broken symlink should
			# try to create the file the link points to
			parent, newname, _ = self.lookup_for_edit(existing.target,
								  should_exist=False,
								  override_cwd=parent)

		if args.generate is None:
			if args.data is None:
				newdata = confirmed_getpass("data for %s" % args.path)
			else:
				newdata = args.data
		else:
			if args.data is not None:
				error("'-g' and '-D' are mutually exclusive")
			newdata = generate_data(args.generate, args.punctuation_chars)

		parent[newname] = File(newdata)

	@writer([generate_arg, data_arg, punctchars_arg, mk_path_arg("the file to edit")],
		parser_kwargs=dict(epilog=gen_constraints_desc,
				   formatter_class=argparse.RawDescriptionHelpFormatter))
	def edit(self, args: Args) -> None:
		"""edit an existing file"""
		editfile = self.lookup_path(args.path)

		if not isinstance(editfile, File):
			error("'%s' is not a file" % args.path)

		if args.generate is None:
			if args.data is None:
				newdata = confirmed_getpass("new data for %s" % args.path)
			else:
				newdata = args.data
		else:
			if args.data is not None:
				error("'-g' and '-D' are mutually exclusive")
			newdata = generate_data(args.generate, args.punctuation_chars)

		editfile.data = newdata
		editfile.touch()

	@writer([force_overwrite_arg,
		 (("frompath",), dict(metavar="FROM", help="current path/name")),
		 (("topath",), dict(metavar="TO", help="new path/name"))])
	def mv(self, args: Args) -> None:
		"""move/rename a file or directory"""
		curparent, objname, obj = self.lookup_for_edit(args.frompath, should_exist=True)
		topath = args.topath
		newparent, newobjname, victim = self.lookup_for_edit(topath, should_exist=None)

		# mypy, known because of should_exist=True when we looked it up
		assert obj is not None

		# If topath is a symlink to a directory, move frompath
		# into the pointed-to directory.  (If topath is a
		# symlink to file or a broken symlink however, just
		# replace the link itself with frompath.)

		if victim is not None and isinstance(victim, Symlink):
			assert victim.target is not None # mypy
			try:
				victim = self.lookup_path(victim.target, override_cwd=newparent)
			except PathLookupError:
				victim = None

		if victim is not None and isinstance(victim, Directory):
			newparent = victim
			newobjname = objname
			topath = os.path.join(args.topath, objname)

		p = newparent
		while p[os.path.pardir] is not p:
			if p is obj:
				error("can't move '%s' inside itself" % args.frompath)
			tmp = p[os.path.pardir]
			assert isinstance(tmp, Directory)
			p = tmp

		if newobjname in newparent:
			existing = newparent[newobjname]
			if not args.force:
				error("'%s' exists (use '-f' to overwrite)" % topath)
			elif is_nonempty_dir(existing):
				error("'%s' is not empty" % topath)

		newparent[newobjname] = obj
		if isinstance(obj, Directory):
			obj.set_parent(newparent)
		del curparent[objname]

	@writer([(("target",), dict(help="link target")),
		 (("linkpath",), dict(help="link to create"))])
	def ln(self, args: Args) -> None:
		"""create a symlink"""
		parent, newname, obj = self.lookup_for_edit(args.linkpath, should_exist=None)
		if obj is not None and isinstance(obj, Symlink):
			assert obj.target is not None # mypy
			obj = self.lookup_path(obj.target, override_cwd=parent)

		if obj is not None:
			# if linkpath is a directory (or a symlink to one),
			# create the symlink linkpath/basename(target) instead
			if isinstance(obj, Directory):
				parent = obj
				newname = path_components(args.target)[-1]
			else:
				error("'%s' exists" % args.linkpath)

		if newname in parent:
			error("'%s' exists" % args.linkpath)

		parent[newname] = Symlink(args.target)

	@writer([(("-r", "--recursive"), dict(help="recursively delete directory contents",
					      action="store_true")),
		 mk_path_arg("the file or directory to delete", multiple_ok=True)])
	def rm(self, args: Args) -> None:
		"""delete a file or directory"""
		errors = []
		for path in args.paths:
			parent, tgtname, obj = self.lookup_for_edit(path, should_exist=True)

			# mypy, known due to should_exist=True when we looked up
			assert obj is not None

			if obj is self.data:
				errors.append("can't delete '%s'" % os.sep)
			elif is_nonempty_dir(obj) and not args.recursive:
				errors.append("'%s' is not empty" % path)
			else:
				del parent[tgtname]
		if len(errors) > 0:
			error("; ".join(errors))

	@reader([mk_path_arg("the file to print"), force_print_arg])
	def cat(self, args: Args) -> None:
		"""print a file to stdout"""
		item = self.lookup_path(args.path)

		if isinstance(item, Directory):
			error("'%s' is a directory" % args.path)

		assert item.data is not None # mypy

		self.output_secret(item.data, args.force)

	@reader([mk_path_arg("starting directory (default: .)", opt=True),
		 (("-a", "--all"), dict(help="don't skip entries starting with '.'", action="store_true"))])
	def find(self, args: Args) -> None:
		"""recursively list files and directories"""
		if args.path is None:
			paths = recursive_list(self.cwd, os.path.curdir, skip_dots=not args.all)
		else:
			paths = recursive_list(self.lookup_path(args.path), args.path, skip_dots=not args.all)
		sys.stdout.write('\n'.join(paths) + '\n')

	@reader([mk_path_arg("file or directory to list (default: .)", opt=True),
		 (("-a", "--all"), dict(help="don't skip entries starting with '.'", action="store_true")),
		 (("-l", "--long"), dict(help="use long format", action="store_true")),
		 (("-r", "--reverse"), dict(help="reverse sort order", action="store_true")),
		 (("-t", "--time-sort"), dict(help="sort by modification time", action="store_true")),
		 (("-d", "--directory"), dict(help="list directories themselves, not their contents",
					      action="store_true"))])
	def ls(self, args: Args) -> None:
		"""list files and directories"""
		if args.path is not None:
			path = args.path
			obj = self.lookup_path(path)
		else:
			path = os.path.curdir
			obj = self.cwd

		def lsfmt(objs: Sequence[Tuple[str, FSObj]]) -> str:
			if len(objs) == 0:
				return ''
			# longest mhost length
			hostlen = max(len(o[1].mhost) for o in objs) # type: ignore[arg-type]
			nometa = "????-??-?? ??:??:?? ???"
			timefmt = "%Y-%m-%d %H:%M:%S"
			def fmtitem(item: Tuple[str, FSObj]) -> str:
				name, obj = item
				main = name
				if isinstance(obj, Directory):
					main += os.path.sep

				if args.long:
					pfx = time.strftime(timefmt, time.localtime(obj.mtime))
					pfx += " %s  %*s  " % (obj.mtzname, hostlen, obj.mhost)
					if isinstance(obj, Symlink):
						main += " -> %s" % obj.target
				else:
					pfx = ""

				return pfx + main
			return '\n'.join(fmtitem(i) for i in objs if not isdot(i[0]) or args.all) + '\n'

		if isinstance(obj, File) or args.directory:
			objs = [(path, cast(FSObj, obj))]
		else:
			sortkey = (lambda t: t[1].mtime) if args.time_sort else (lambda n: n)
			objs = sorted(obj.items(), key=sortkey, reverse=args.reverse)
		sys.stdout.write(lsfmt(objs))

	def _menu_select(self, items: List[str]) -> Optional[str]:
		argv = ["dmenu", "-i", "-b", "-l", "20", "-p", self.filename,
			"-nb", "#600", "-nf", "white", "-sb", "white", "-sf", "black"]
		try:
			proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stdin=subprocess.PIPE)
		except OSError as ose:
			if ose.errno == errno.ENOENT:
				raise UserError("Error attempting to execute %s: %s"
						% (argv[0], ose.strerror))
			else:
				raise

		outb, _ = proc.communicate(b'\n'.join([i.encode("utf-8") for i in items]) + b'\n')
		out = outb.decode("utf-8")
		retval = proc.wait()
		if retval != 0 or out == '':
			return None
		else:
			assert out[-1] == '\n'
			return out[:-1]

	@reader([mk_path_arg("path to select beneath (default: .)", opt=True), force_print_arg,
		 (("-a", "--all"), dict(help="don't skip entries starting with '.'", action="store_true"))])
	def menu(self, args: Args) -> None:
		"""print a file selected from a menu (requires `dmenu')"""
		if args.path is not None:
			prefix = args.path
			top = self.lookup_path(prefix)
		else:
			prefix = os.path.curdir
			top = self.cwd
		paths = recursive_list(top, prefix, fileonly=True, skip_dots=not args.all)
		selected = self._menu_select(sorted(paths))
		if selected is None:
			print("(Nothing selected.)", file=sys.stderr)
			exit(1)
		item = self.lookup_path(selected)
		assert isinstance(item, File)
		assert item.data is not None # mypy
		self.output_secret(item.data, args.force)

	@reader([(("-p", "--pipe-to"), dict(metavar="CMD", type=str, default=None,
					    help="shell command to pipe output to for commands"
					    " that print file data")),
		 (("-e", "--exit-on-failure"), dict(help="exit on command failure",
						    action="store_true"))],
		batch_only=True)
	def shell(self, args: Args) -> None:
		"""start an interactive jemf shell"""
		sh = JemfShell(self, args)

		self.shell_mode = True

		for cmd in self.commands:
			if cmd.batch_only or (self.read_only and not cmd.read_only):
				continue

			# get the "jemf" prefix out of the help message
			cmd.parser.prog = cmd.name

			# hack around argparse's inability to report
			# an error without exiting...
			cmd.parser.error = mk_raise_usage_error(cmd.parser.format_usage()) # type: ignore[assignment]

			sh._add_cmd_handler(cmd)

		# saving and restoring self.pipe_to here is kind of
		# overkill, but aesthetically slightly preferable to
		# clobbering it completely.
		orig_pipe_to = self.pipe_to
		self.pipe_to = args.pipe_to
		while True:
			try:
				sh.cmdloop()
			except KeyboardInterrupt:
				sys.stdout.write("^C\n")
				continue
			else:
				break
		self.pipe_to = orig_pipe_to

		# likewise, this shouldn't really be necessary, but
		# just for sake of cleanliness...
		self.shell_mode = False

unlock_hook = None

def lock_fsfile(fspath: str) -> None:
	# canonicalize path to avoid problems if fspath is a symlink
	# (in which case two processes could end up accessing the same
	# fsfile using two different locks)
	lockpath = os.path.realpath(fspath) + '.lock'

	# Use a symlink as a lock (pointing to some lock metadata as a
	# string), emacs-style -- this has the advantage of being able
	# to be created and written in one atomic step, as opposed to
	# a regular file where the creation and data-writing are
	# separate steps (meaning another process could observe the
	# odd in-between state)
	lockdata = "%s@%s.%d" % (lib_getuser(), get_hostname(), os.getpid())
	try:
		os.symlink(lockdata, lockpath)
	except OSError as e:
		if e.errno == errno.EEXIST:
			try:
				owner = os.readlink(lockpath)
				error("%s is currently locked by %s" % (fspath, owner))
			except OSError as e2:
				# there's a chance the lock could have
				# been released between when we
				# attempted to acquire it and now (as
				# we try to read owner info from it
				# after failing) -- if so, just leave
				# the retry to the user.
				base = "%s was locked, but " % fspath
				if e2.errno == errno.ENOENT:
					extra = "appears to have been released"
				else:
					extra = "unable to determine owner: %s" % e2.strerror
				error(base + extra)
		else:
			error("failed to lock %s: %s" % (fspath, e.strerror))

	# This handler remains registered in the foreground process in
	# persist mode (i.e. the original process releases the lock
	# when it exits, leaving the background process running
	# without holding it).  It's the client's responsibility to
	# acquire the lock before connecting to the server for a given
	# fsfile (given the in-memory caching done by clients, having
	# multiple clients concurrently operating on the same fsfile
	# would still be problematic even if doing so via a single
	# server process).
	#
	# We do want to unregister it in the forked child though (so
	# that it doesn't try to spuriously unlink the lockfile
	# itself), so we save a copy of the hook so it can do so.
	global unlock_hook
	unlock_hook = atexit.register(lambda: os.unlink(lockpath))

def open_jemf_fs(args: Args, skip_locking: bool=False) -> Jemf:
	if not skip_locking:
		lock_fsfile(args.fsfile)

	# First try to open via a persistent server process
	sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
	try:
		sock.connect(get_sockpath(args.fsfile))
		return Jemf.from_server(sock, args.fsfile, args.read_only)
	except ProtocolError:
		raise
	except:
		sock.close()

	if not os.path.exists(args.fsfile):
		error("'%s' does not exist" % args.fsfile)
	password = getpass("Enter password for %s: " % args.fsfile)
	return Jemf.load_from_path(args.fsfile, password, args.read_only)

def new_jemf_fs(args: Args, skip_locking: bool=False) -> Jemf:
	if os.path.exists(args.fsfile) and not args.force:
		error("%s exists (use -f to force reinitialization)" % args.fsfile)

	if not skip_locking:
		lock_fsfile(args.fsfile)

	# It'd be nice if python had a do-while loop...
	password = ""
	while password == "":
		password = confirmed_getpass("password for new jemf FS (%s)" % args.fsfile)
		if password == "":
			errprint("Empty password not allowed.")

	return Jemf(args.fsfile, password, args.read_only)

def get_sockdir() -> str:
	uid = os.getuid()
	return "/tmp/.jemf-%d" % uid

def get_sockpath(fsfile: str) -> str:
	return get_sockdir() + "/" + hashlib.sha1(os.path.realpath(fsfile).encode("utf-8")).hexdigest()

def get_socket(fsfile: str) -> socket.socket:
	sockdir = get_sockdir()
	try:
		os.mkdir(sockdir, 0o700)
	except OSError as e:
		if e.errno == errno.EEXIST:
			os.chmod(sockdir, 0o700)
		else:
			raise
	sockpath = get_sockpath(fsfile)
	sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
	sock.bind(sockpath)
	os.chmod(sockpath, 0o600)

	return sock

# Arbitrary data fields are sent base64-encoded so as to guarantee
# absence of spaces and LFs (which are significant).
#
# Request: 'READ' SP base64(fsfile-path) LF
# Response:
#   'OK' SP base64(json) LF
#   'ERROR' SP error-message LF
#
# Request: 'WRITE' SP base64(fsfile-path) SP base64(json) LF
# Response:
#   'OK' LF
#   'ERROR' SP error-message LF
#
# Request: 'EXIT' LF
# Response: 'OK' LF

EXIT_REQ = b"EXIT"
READ_REQ = b"READ"
WRITE_REQ = b"WRITE"

def err_resp(s: str) -> bytes:
	return b"ERROR %s\n" % s.encode("utf-8")

def serve_request(line: bytes, fs: Jemf, client: socket.socket) -> None:
	fields = line.split(b' ')
	req = fields[0]
	if req == EXIT_REQ:
		os.kill(os.getppid(), signal.SIGTERM)
		client.sendall(b"OK\n")
		os._exit(0)
	elif req == READ_REQ:
		if len(fields) != 2:
			resp = err_resp("wrong number of arguments to READ")
		else:
			path = base64.b64decode(fields[1]).decode("utf-8")
			if path == fs.filename:
				resp = b"OK %s\n" % base64.b64encode(fs._to_json().encode("utf-8"))
			else:
				resp = err_resp("wrong filename")
	elif req == WRITE_REQ:
		if len(fields) != 3:
			resp = err_resp("wrong number of arguments to WRITE")
		elif fs.read_only:
			resp = err_resp("server in read-only mode")
		else:
			path, data = [base64.b64decode(s).decode("utf-8") for s in fields[1:]]
			if path == fs.filename:
				try:
					fs._from_json(data)
					fs._write_out()
					resp = b"OK\n"
				except Exception as ex:
					resp = err_resp("update failed: %s\n" % str(ex.args))
			else:
				resp = err_resp("wrong filename")
	else:
		resp = err_resp("invalid request")
	client.sendall(resp)

def serve_client(client: socket.socket, fs: Jemf) -> None:
	buf = b''
	while True:
		t = client.recv(SOCKBUF_SIZE)
		if len(t) == 0:
			break
		else:
			buf += t
			if b'\n' in t:
				lines = buf.split(b'\n')
				buf = lines[-1]
				for line in lines[:-1]:
					serve_request(line, fs, client)

# format of the struct ucred that SO_PEERCRED provides (i32 pid_t
# followed by u32 uid_t & gid_t)
UCRED_FMT = "iII"

# return a (pid, uid, gid) tuple of the local socket's peer
def getpeercred(sock: socket.socket) -> Tuple[int, int, int]:
	pc = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize(UCRED_FMT))
	p, u, g = struct.unpack(UCRED_FMT, pc)
	return (p, u, g)

def check_client(sock: socket.socket) -> bool:
	pid, uid, gid = getpeercred(sock)

	# filesystem permissions *should* prevent other users from
	# being able to access the socket, but...belt and suspenders
	if uid != os.getuid() or gid != os.getgid():
		return False

	# We don't want to hassle with interactive confirmation if the
	# client is our own parent (the original jemf process that
	# forked the background server), which is typically the first
	# client to connect, so allow a special-case bypass for it.
	if pid == os.getppid():
		return True

	return confirm_gui("Allow jemf access for pid %d?" % pid)

def serve_fs(fs: Jemf, timeout: int) -> None:
	srvsock = get_socket(fs.filename)

	# ensure the child we're about to fork doesn't inherit a
	# non-empty stdout buffer (which would result in duplicate
	# output if it then gets flushed by both processes)
	sys.stdout.flush()

	# create a pipe so the parent can block until the child
	# notifies it that it's ready and listening (by closing the
	# write end)
	ready_r, ready_w = pipe()

	pid = os.fork()
	if pid != 0:
		os.close(ready_w)
		os.read(ready_r, 1)
		os.close(ready_r)
	else:
		os.close(ready_r)
		for f in [sys.stdin, sys.stdout, sys.stderr]:
			f.close()
		fd = os.open("/dev/null", os.O_RDWR)
		os.dup2(fd, 0)
		os.dup2(fd, 1)
		os.dup2(fd, 2)
		if fd > 2:
			os.close(fd)

		global unlock_hook
		if unlock_hook is not None:
			atexit.unregister(unlock_hook)
			unlock_hook = None

		srvsock.listen(8)

		def exit_handler(signum: int, frame: Optional[FrameType]) -> None:
			try:
				os.unlink(get_sockpath(fs.filename))
			except:
				pass
			exit(0)

		signal.signal(signal.SIGTERM, exit_handler)
		signal.signal(signal.SIGALRM, exit_handler)

		os.close(ready_w)

		try:
			while True:
				signal.alarm(timeout)
				try:
					client, _ = srvsock.accept()
				except socket.error as err:
					if err.errno == errno.EINTR:
						continue
					else:
						raise

				if check_client(client):
					signal.alarm(0)

					try:
						serve_client(client, fs)
					except:
						os._exit(1)
				else:
					client.sendall(err_resp("access denied"))
				client.close()
		except:
			try:
				os.unlink(get_sockpath(fs.filename))
			except:
				pass
			exit(1)

def main() -> None:
	mainparser = argparse.ArgumentParser(description="a jeneric (?), encrypted mini-filesystem",
					     epilog="FS file can also be specified with the environment"
					     " variable JEMF_FSFILE")

	mainparser.add_argument("-f", "--fsfile", type=str, help="jemf FS file to use")
	mainparser.add_argument("-r", "--read-only", action="store_true",
				help="prohibit modifications")
	mainparser.add_argument("-X", "--gui", action="store_true",
				help="Use GUI sub-programs for password input and status output"
				     " ([gnome-]ssh-askpass and zenity/[g]xmessage)")
	mainparser.add_argument("-P", "--persist", type=int, help="fork a background server process for "
				"passphrase-less operation of subsequent invocations within NSEC seconds",
				metavar="NSEC", default=0)

	subparsers = mainparser.add_subparsers(metavar="COMMAND", title=None) # type: ignore[call-overload]
	subparsers.required = True

	# sorted for ordering in --help output
	for cmd in sorted(Jemf.commands, key=lambda c: c.name):
		if not cmd.shell_only:
			cmd.add_to(subparsers)
		else:
			cmd.create_parser()

	try:
		args = mainparser.parse_args()
	except HelpFlagPassed:
		exit(0)

	if args.gui:
		global getpass_interactive, errprint
		getpass_interactive = getpass_gui
		errprint = errprint_gui

	if args.fsfile is None:
		for p in [os.getenv("JEMF_FSFILE"), find_fs()]:
			if p is not None:
				args.fsfile = p
				break

	if args.read_only and not args.action.read_only:
		error("%s cannot be performed in read-only mode" % args.action.name)

	doing_mkfs = args.action is Jemf.mkfs

	# Early check for things that might print secrets to stdout
	# (to avoid the annoyance of entering your passphrase only to
	# then find out that you forgot a flag and now have to do it
	# over again).
	if Jemf.force_print_arg in args.action.cmd_args:
		check_tty_print_interlock(args.force)

	if args.fsfile is None:
		if doing_mkfs:
			args.fsfile = default_fspaths[0]
		else:
			error("no jemf FS found or specified")

	ctor = new_jemf_fs if doing_mkfs else open_jemf_fs
	try:
		jfs = ctor(args)
		if args.persist > 0 and jfs.socket is None:
			serve_fs(jfs, args.persist)
			jfs = ctor(args, skip_locking=True)
		args.action(jfs, args)
	except KeyboardInterrupt:
		sys.stdout.write("^C\n")
		exit(1)

if __name__ == "__main__":
	try:
		main()
	except subprocess.CalledProcessError as cpe:
		cmdname = cpe.cmd[0] if type(cpe.cmd) is list else cpe.cmd.split()[0]
		print("[%s exited with status %d]" % (cmdname, cpe.returncode), file=sys.stderr)
		exit(1)
	except (UserError, ProtocolError) as ex:
		if ex.args[0]:
			errprint(ex.args[0])
		exit(1)
	except InternalError as ex:
		report_internal_error(ex)
		exit(1)
