From fd25cf2ecc4390465e9f49bda6fbbc192e1fe108 Mon Sep 17 00:00:00 2001 From: Daniel Moch Date: Sat, 15 Jan 2022 07:45:50 -0500 Subject: [PATCH] Initial commit --- .gitignore | 2 + LICENSE | 17 + README.md | 49 + pyproject.toml | 45 + requirements-dev.txt | 48 + src/py9pfactotum/__init__.py | 20 + src/py9pfactotum/_vendor/LICENSE | 21 + src/py9pfactotum/_vendor/__init__.py | 0 src/py9pfactotum/_vendor/py9p/__init__.py | 22 + src/py9pfactotum/_vendor/py9p/fuse9p.py | 614 +++++++ src/py9pfactotum/_vendor/py9p/pki.py | 412 +++++ src/py9pfactotum/_vendor/py9p/py9p.py | 1766 +++++++++++++++++++++ src/py9pfactotum/_vendor/py9p/utils.py | 13 + src/py9pfactotum/client.py | 138 ++ src/py9pfactotum/errors.py | 10 + src/py9pfactotum/keyring.py | 53 + src/py9pfactotum/util.py | 17 + tox.ini | 11 + 18 files changed, 3258 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 src/py9pfactotum/__init__.py create mode 100644 src/py9pfactotum/_vendor/LICENSE create mode 100644 src/py9pfactotum/_vendor/__init__.py create mode 100644 src/py9pfactotum/_vendor/py9p/__init__.py create mode 100755 src/py9pfactotum/_vendor/py9p/fuse9p.py create mode 100644 src/py9pfactotum/_vendor/py9p/pki.py create mode 100644 src/py9pfactotum/_vendor/py9p/py9p.py create mode 100644 src/py9pfactotum/_vendor/py9p/utils.py create mode 100644 src/py9pfactotum/client.py create mode 100644 src/py9pfactotum/errors.py create mode 100644 src/py9pfactotum/keyring.py create mode 100644 src/py9pfactotum/util.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd99803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.tox/ +dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c9884cb --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +ISC License (ISC) + +Copyright 2022 Daniel Moch + +Permission to use, copy, modify, and/or distribute this software +for any purpose with or without fee is hereby granted, provided +that the above copyright notice and this permission notice appear +in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL +DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA +OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6501033 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +py9pfactotum +============ + +py9pfactotum is Python's plan9port Factotum client. It also provides +the following extras: + +- Helper functions that mirror plan9port's auth(3) library +- A [Keyring](https://pypi.org/project/keyring/) backend to connect + to your running factotum server. + +Usage +----- + +In the current (alpha) state of maturity, the implementation is +incomplete. Currently enough is implemented to support the Keyring +backend (i.e., to read passwords from the running server). + +When using the FactotumClient directly, arguments are passed as +keyword arguments, which are built into a key template internally: + +``` +from py9pfactotum import FactotumClient +c = FactotumClient() +c.getpass(server='mail.example.org', user='johndoe') +{ 'user': 'johndoe', 'passwd': 'insecure' } +``` + +As with the example above, the client provides high-level methods +that supply the correct 'proto' and 'role' attributes to the key +template. See factotum(4) for more information. + +When using the auth(3) functions, the function signatures mirror +their plan9port counterparts, except that structures are replaced +with dictionaries. + +``` +from py9pfactotum import auth_getuserpasswd +c = auth_getuserpasswd(server='mail.example.org') +{ 'user': 'johndoe', 'passwd': 'insecure' } +``` + +Keyring +------- + +The Keyring user interface is transparent, but that library must +be explicitly installed on the system (it is not a hard dependency +of py9pfactotum). Because factotum is not able to persist passwords +(or password deletions), attempts to use the Keyring to do so will +throw an error. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..74ffa5d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "py9pfactotum" +authors = [ + {name = "Daniel Moch", email = "daniel@danielmoch.com"} +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: ISC License (ISCL)", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", +] +readme = "README.md" +requires-python = ">=3.7" +dynamic = ["version", "description"] + +[project.urls] +Home = "https://git.danielmoch.com/py9pfactotum" + +[project.optional-dependencies] +dev = [ + "flake8", + "flit", + "keyring", + "mypy", +] + +[project.entry-points."keyring.backends"] +factotum = "py9pfactotum.keyring" + +[tool.mypy] +exclude = "/_vendor/" + +[[tool.mypy.overrides]] +module = [ + "py9pfactotum._vendor.py9p.py9p", + "py9pfactotum._vendor.py9p.utils", +] +follow_imports = "silent" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..35f8553 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,48 @@ +# +# This file is autogenerated by pip-compile with python 3.9 +# To update, run: +# +# pip-compile --extra=dev --output-file=requirements-dev.txt pyproject.toml +# +certifi==2021.10.8 + # via requests +charset-normalizer==2.0.10 + # via requests +docutils==0.18.1 + # via flit +flake8==4.0.1 + # via factotum (pyproject.toml) +flit==3.6.0 + # via factotum (pyproject.toml) +flit-core==3.6.0 + # via flit +idna==3.3 + # via requests +importlib-metadata==4.10.0 + # via keyring +keyring==23.5.0 + # via factotum (pyproject.toml) +mccabe==0.6.1 + # via flake8 +mypy==0.931 + # via factotum (pyproject.toml) +mypy-extensions==0.4.3 + # via mypy +pycodestyle==2.8.0 + # via flake8 +pyflakes==2.4.0 + # via flake8 +requests==2.27.1 + # via flit +tomli==2.0.0 + # via + # flit + # mypy +tomli-w==1.0.0 + # via flit +typing-extensions==4.0.1 + # via mypy +urllib3==1.26.8 + # via requests +zipp==3.7.0 + # via importlib-metadata diff --git a/src/py9pfactotum/__init__.py b/src/py9pfactotum/__init__.py new file mode 100644 index 0000000..6ef1b80 --- /dev/null +++ b/src/py9pfactotum/__init__.py @@ -0,0 +1,20 @@ +""" +The factotum module implements a plan9port's auth(3) library. +""" +from typing import Dict +from .client import FactotumClient + +__version__ = '0.1.0.dev0' + + +def auth_getuserpasswd(**kwargs: str) -> Dict[str, str]: + """ + Retrieve a password from the factotum server. Kwargs should + contain the desired key template. Returns a dictionary containing + user and passwd keys. + + Proto and role may optionally be omitted from the key template; + they are set within auth_getuserpasswd. + """ + c = FactotumClient() + return c.getpass(**kwargs) diff --git a/src/py9pfactotum/_vendor/LICENSE b/src/py9pfactotum/_vendor/LICENSE new file mode 100644 index 0000000..9ba9403 --- /dev/null +++ b/src/py9pfactotum/_vendor/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2008-2011 Tim Newsham, Andrey Mirtchovski +Copyright (c) 2011-2012 Peter V. Saveliev + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/py9pfactotum/_vendor/__init__.py b/src/py9pfactotum/_vendor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/py9pfactotum/_vendor/py9p/__init__.py b/src/py9pfactotum/_vendor/py9p/__init__.py new file mode 100644 index 0000000..0006390 --- /dev/null +++ b/src/py9pfactotum/_vendor/py9p/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2011-2012 Peter V. Saveliev +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +__version__ = "1.0.9" diff --git a/src/py9pfactotum/_vendor/py9p/fuse9p.py b/src/py9pfactotum/_vendor/py9p/fuse9p.py new file mode 100755 index 0000000..233dafb --- /dev/null +++ b/src/py9pfactotum/_vendor/py9p/fuse9p.py @@ -0,0 +1,614 @@ +# Copyright (c) 2011-2012 Peter V. Saveliev +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import socket +import sys +import os +import pwd +import grp +import fuse +import stat +import errno +import time +import threading +import py9p +import traceback + +MIN_TFID = 64 +MAX_TFID = 1023 +MIN_FID = 1024 +MAX_FID = 65535 +MAX_RECONNECT_INTERVAL = 1024 +IOUNIT = 1024 * 16 +FAIL_TRIES = 2 +FAIL_TIMEOUT = 0.5 + +uid_map = {} +gid_map = {} +rpccodes = { + py9p.Eunknownfid: -errno.EBADFD, + py9p.Edupfid: -errno.EBADFD, + py9p.Ebaddir: -errno.EBADFD, + py9p.Enocreate: -errno.EPERM, + py9p.Enoremove: -errno.EPERM, + py9p.Enostat: -errno.EPERM, + py9p.Enowstat: -errno.EPERM, + py9p.Eperm: -errno.EPERM, + py9p.Enotfound: -errno.ENOENT, + py9p.Eisdir: -errno.EISDIR, + py9p.Ewalknotdir: -errno.ENOTDIR, + py9p.Ecreatenondir: -errno.ENOTDIR} + + +class Error(py9p.Error): + pass + + +class NoFidError(Exception): + pass + + +fuse.fuse_python_api = (0, 2) + + +class fStat(fuse.Stat): + """ + FUSE stat structure, that will represent PyVFS Inode + """ + def __init__(self, inode): + self.st_mode = py9p.mode2stat(inode.mode) + self.st_ino = 0 + self.st_dev = 0 + if inode.mode & stat.S_IFDIR: + self.st_nlink = inode.length + else: + self.st_nlink = 1 + self.st_uid = int(uid_map.get(inode.uidnum, inode.uidnum)) + self.st_gid = int(gid_map.get(inode.gidnum, inode.gidnum)) + self.st_size = inode.length + self.st_atime = inode.atime + self.st_mtime = inode.mtime + self.st_ctime = inode.mtime + + +class fakeRoot(fuse.Stat): + """ + Fake empty root for disconnected state + """ + def __init__(self): + self.st_mode = stat.S_IFDIR | 0o755 + self.st_ino = 0 + self.st_dev = 0 + self.st_nlink = 3 + self.st_uid = 0 + self.st_gid = 0 + self.st_size = 3 + self.st_atime = self.st_mtime = self.st_ctime = time.time() + + +def guard(c): + """ + The decorator function, specific for ClientFS class + + * acqiures and releases temporary fid + * deals with py9p RPC errors + * triggers reconnect() on network errors + """ + def wrapped(self, *argv, **kwarg): + ret = -errno.EIO + for i in range(FAIL_TRIES): + try: + tfid = self.tfidcache.acquire() + with self._rlock: + ret = c(self, tfid.fid, *argv, **kwarg) + self.tfidcache.release(tfid) + break + except NoFidError: + ret = -errno.EMFILE + break + except py9p.RpcError as e: + ret = rpccodes.get(e.message.lower(), -errno.EIO) + break + except: + if self.debug: + traceback.print_exc() + if self.keep_reconnect: + self._reconnect() + time.sleep(FAIL_TIMEOUT) + else: + sys.exit(255) + return ret + return wrapped + + +class FidCache(dict): + """ + Fid cache class + + The class provides API to acquire next not used Fid + for the 9p operations. If there is no free Fid available, + it raises NoFidError(). After usage, Fid should be freed + and returned to the cache with release() method. + """ + def __init__(self, start=MIN_FID, limit=MAX_FID): + """ + * start -- the Fid interval beginning + * limit -- the Fid interval end + + All acquired Fids will be from this interval. + """ + dict.__init__(self) + self.start = start + self.limit = limit + self.iounit = IOUNIT + self.fids = list(range(self.start, self.limit + 1)) + + def acquire(self): + """ + Acquire next available Fid + """ + if len(self.fids) < 1: + raise NoFidError() + return Fid(self.fids.pop(0), self.iounit) + + def release(self, f): + """ + Return Fid to the free Fids queue. + """ + self.fids.append(f.fid) + + +class Fid(object): + """ + Fid class + + It is used also in the stateful I/O, representing + the open file. All methods, working with open files, + will receive Fid as the last parameter. + + See: write(), read(), release() + """ + def __init__(self, fid, iounit=IOUNIT): + self.fid = fid + self.iounit = iounit + + +class ClientFS(fuse.Fuse): + """ + FUSE subclass + + Implements all the proxying of FUSE calls to 9p + server. Can authomatically reconnect to the server. + """ + def __init__(self, address, credentials, mountpoint, + debug=False, timeout=10, keep_reconnect=False): + """ + * address -- (address,port) of the 9p server, tuple + * credentials -- py9p.Credentials + * mountpoint -- where to mount the FS + * debug -- FUSE and py9p debug output, implies foreground run + * timeout -- socket timeout + * keep_reconnect -- whether to try reconnect after errors + """ + + self.address = address + self.credentials = credentials + self.debug = debug + self.timeout = timeout + self.msize = IOUNIT + self.sock = None + self.exit = None + self.dotu = 1 + self.keep_reconnect = keep_reconnect + self._lock = threading.Lock() + self._rlock = threading.RLock() + self._interval = 1 + self._reconnect_event = threading.Event() + self._connected_event = threading.Event() + self.fidcache = FidCache() + self._reconnect(init=True) + self.dircache = {} + self.tfidcache = FidCache(start=MIN_TFID, limit=MAX_TFID) + + fuse.Fuse.__init__(self, version="%prog " + fuse.__version__, + dash_s_do='undef') + + if debug: + self.fuse_args.setmod('foreground') + self.fuse_args.add('debug') + self.fuse_args.add('large_read') + self.fuse_args.add('big_writes') + self.fuse_args.mountpoint = os.path.realpath(mountpoint) + + def fsinit(self): + # daemon mode RNG hack for PyCrypto + try: + from Crypto import Random + Random.atfork() + except: + pass + + def _reconnect(self, init=False, dotu=1): + """ + Start reconnection thread. When init=True, just probe + the connection and return even if keep_reconnect=True. + """ + if self._lock.acquire(False): + self._connected_event.clear() + t = threading.Thread( + target=self._reconnect_target, + args=(init, dotu)) + t.setDaemon(True) + t.start() + if init: + # in the init state we MUST NOT leave + # any thread; all running threads will be + # suspended by FUSE in the "daemon" + # multithreaded mode + t.join() + else: + # otherwise, just run reconnection + # thread in the background + self._connected_event.wait(self.timeout + 2) + if self.exit: + print(str(self.exit)) + sys.exit(255) + + def _reconnect_interval(self): + """ + Return next reconnection interval in seconds. + """ + self._interval = min(self._interval * 2, MAX_RECONNECT_INTERVAL) + return self._interval + + def _reconnect_target(self, init=False, dotu=1): + """ + Reconnection thread code. + """ + while True: + try: + self.sock.close() + except: + pass + + try: + if self.debug: + print("trying to connect") + if self.address[0].find("/") > -1: + self.sock = socket.socket(socket.AF_UNIX) + else: + self.sock = socket.socket(socket.AF_INET) + self.sock.settimeout(self.timeout) + self.sock.connect(self.address) + self.client = py9p.Client( + fd=self.sock, + chatty=self.debug, + credentials=self.credentials, + dotu=dotu, msize=self.msize) + self.msize = self.client.msize + self.fidcache.iounit = self.client.msize - py9p.IOHDRSZ + self._connected_event.set() + self._lock.release() + return + except py9p.VersionError: + if dotu: + self.dotu = 0 + self._reconnect_target(init, 0) + else: + self.exit = Exception("protocol negotiation error") + return + except Exception as e: + if self.keep_reconnect: + if init: + # if we get an error on the very initial + # time, just fake the connection -- + # next reconnect round will be triggered + # by the next failed FS call + self._lock.release() + self._connected_event.set() + return + s = self._reconnect_interval() + if self.debug: + print("reconnect in %s seconds" % (s)) + self._reconnect_event.wait(s) + self._reconnect_event.clear() + else: + self.exit = e + self._lock.release() + self._connected_event.set() + return + + @guard + def open(self, tfid, path, mode): + f = self.fidcache.acquire() + try: + self.client._walk(self.client.ROOT, + f.fid, filter(None, path.split("/"))) + fcall = self.client._open(f.fid, py9p.open2plan(mode)) + f.iounit = fcall.iounit + return f + except Exception as e: + self.fidcache.release(f) + raise e + + @guard + def _wstat(self, tfid, path, + uid=py9p.ERRUNDEF, + gid=py9p.ERRUNDEF, + mode=py9p.ERRUNDEF, + newname=None): + self.client._walk(self.client.ROOT, + tfid, filter(None, path.split("/"))) + if self.dotu: + stats = [py9p.Dir( + dotu=1, + type=0, + dev=0, + qid=py9p.Qid(0, 0, py9p.hash8(path)), + mode=mode, + atime=int(time.time()), + mtime=int(time.time()), + length=py9p.ERRUNDEF, + name=newname or path.split("/")[-1], + uid="", + gid="", + muid="", + extension="", + uidnum=uid, + gidnum=gid, + muidnum=py9p.ERRUNDEF), ] + else: + stats = [py9p.Dir( + dotu=0, + type=0, + dev=0, + qid=py9p.Qid(0, 0, py9p.hash8(path)), + mode=mode, + atime=int(time.time()), + mtime=int(time.time()), + length=py9p.ERRUNDEF, + name=newname or path.split("/")[-1], + uid=pwd.getpwuid(uid).pw_name, + gid=grp.getgrgid(gid).gr_name, + muid=""), ] + self.client._wstat(tfid, stats) + self.client._clunk(tfid) + + def chmod(self, path, mode): + return self._wstat(path, mode=py9p.mode2plan(mode)) + + def chown(self, path, uid, gid): + return self._wstat(path, uid, gid) + + def utime(self, path, times): + pass + + @guard + def unlink(self, tfid, path): + self.client._walk(self.client.ROOT, + tfid, filter(None, path.split("/"))) + self.client._remove(tfid) + self.dircache = {} + + def rmdir(self, path): + self.unlink(path) + + @guard + def symlink(self, tfid, target, path): + if not self.dotu: + return -errno.ENOSYS + self.client._walk(self.client.ROOT, tfid, + filter(None, path.split("/"))[:-1]) + self.client._create(tfid, filter(None, path.split("/"))[-1], + py9p.DMSYMLINK, 0, target) + self.client._clunk(tfid) + + @guard + def mknod(self, tfid, path, mode, dev): + if dev != 0: + return -errno.ENOSYS + # FIXME + if not mode & stat.S_IFREG: + mode |= stat.S_IFDIR + try: + self.client._walk(self.client.ROOT, + tfid, filter(None, path.split("/"))) + self.client._open(tfid, py9p.OTRUNC) + self.client._clunk(tfid) + except py9p.RpcError as e: + if e.message == "file not found": + self.client._walk(self.client.ROOT, + tfid, filter(None, path.split("/"))[:-1]) + self.client._create(tfid, + filter(None, path.split("/"))[-1], + py9p.mode2plan(mode), 0) + self.client._clunk(tfid) + else: + return -errno.EIO + + def mkdir(self, path, mode): + return self.mknod(path, mode | stat.S_IFDIR, 0) + + @guard + def truncate(self, tfid, path, size): + if size != 0: + return -errno.ENOSYS + self.client._walk(self.client.ROOT, + tfid, filter(None, path.split("/"))) + self.client._open(tfid, py9p.OTRUNC) + self.client._clunk(tfid) + + @guard + def write(self, tfid, path, buf, offset, f): + if py9p.hash8(path) in self.dircache: + del self.dircache[py9p.hash8(path)] + size = len(buf) + for i in range((size + f.iounit - 1) / f.iounit): + start = i * f.iounit + length = start + f.iounit + self.client._write(f.fid, offset + start, + buf[start:length]) + return size + + @guard + def read(self, tfid, path, size, offset, f): + data = bytes() + i = 0 + while True: + # we do not rely nor on msize, neither on iounit, + # so, shift offset only with real data read + ret = self.client._read(f.fid, offset, + min(size - len(data), f.iounit)) + data += ret.data + offset += len(ret.data) + if size <= len(data) or len(ret.data) == 0: + break + i += 1 + return data[:size] + + @guard + def rename(self, tfid, path, dest): + # the most complicated routine :| + # 9p protocol has no "rename" neither "move" call + # in the meaning of Linux vfs, it can only change + # the name of an entry w/o moving it from dir to + # dir, which can be done with wstat() + + for i in (path, dest): + if py9p.hash8(i) in self.dircache: + del self.dircache[py9p.hash8(i)] + + # if we can use wstat(): + if path.split("/")[:-1] == dest.split("/")[:-1]: + return self._wstat(path, newname=dest.split("/")[-1]) + + # it is not simple rename, fall back to copy/delete: + # + # get source and destination + source = self._getattr(path) + destination = self._getattr(dest) + # abort on EIO + if -errno.EIO in (source, destination): + return -errno.EIO + # create the destination file + if destination == -errno.ENOENT: + self.mknod(dest, source.st_mode, 0) + if source.st_mode & stat.S_IFDIR: + # move all the content to the new directory + for i in self._readdir(path, 0): + self.rename( + "/".join((path, i.name)), + "/".join((dest, i.name))) + else: + # open both files + sf = self.open(path, os.O_RDONLY) + df = self.open(dest, os.O_WRONLY | os.O_TRUNC) + # copy the content + for i in range((source.st_size + self.msize - 1) / self.msize): + block = self.read(path, self.msize, i * self.msize, sf) + self.write(dest, block, i * self.msize, df) + # close files + self.release(path, 0, sf) + self.release(dest, 0, df) + # remove the source + self.unlink(path) + + @guard + def release(self, tfid, path, flags, f): + try: + self.client._clunk(f.fid) + self.fidcache.release(f) + except: + pass + + @guard + def readlink(self, tfid, path): + if py9p.hash8(path) in self.dircache: + return self.dircache[py9p.hash8(path)].extension + self.client._walk(self.client.ROOT, + tfid, filter(None, path.split("/"))) + self.client._open(tfid, py9p.OREAD) + ret = self.client._read(tfid, 0, self.msize) + self.client._clunk(tfid) + return ret.data + + @guard + def _getattr(self, tfid, path): + if py9p.hash8(path) in self.dircache: + return fStat(self.dircache[py9p.hash8(path)]) + + self.client._walk(self.client.ROOT, + tfid, filter(None, path.split("/"))) + ret = self.client._stat(tfid).stat[0] + + s = fStat(ret) + self.client._clunk(tfid) + self.dircache[py9p.hash8(path)] = ret + return s + + def getattr(self, path): + self._interval = 1 + self._reconnect_event.set() + + s = self._getattr(path) + + if s == -errno.EIO: + if self.keep_reconnect: + if path == "/": + return fakeRoot() + else: + return -errno.ENOENT + return s + + @guard + def _readdir(self, tfid, path, offset): + dirs = [] + self.client._walk(self.client.ROOT, + tfid, filter(None, path.split("/"))) + self.client._open(tfid, py9p.OREAD) + offset = 0 + while True: + ret = self.client._read(tfid, offset, self.msize) + if len(ret.data) == 0: + break + offset += len(ret.data) + p9 = py9p.Marshal9P(dotu=self.dotu) + p9.setBuffer(ret.data) + p9.buf.seek(0) + fcall = py9p.Fcall(py9p.Rstat) + p9.decstat(fcall.stat, 0) + dirs.extend(fcall.stat) + self.client._clunk(tfid) + return dirs + + def readdir(self, path, offset): + self._interval = 1 + self._reconnect_event.set() + + dirs = self._readdir(path, offset) + if not isinstance(dirs, list): + dirs = [] + + if path == "/": + path = "" + for i in dirs: + self.dircache[py9p.hash8("/".join((path, i.name)))] = i + yield fuse.Direntry(i.name) diff --git a/src/py9pfactotum/_vendor/py9p/pki.py b/src/py9pfactotum/_vendor/py9p/pki.py new file mode 100644 index 0000000..7dd92a8 --- /dev/null +++ b/src/py9pfactotum/_vendor/py9p/pki.py @@ -0,0 +1,412 @@ +# Copyright (c) 2008-2011 Tim Newsham, Andrey Mirtchovski +# Copyright (c) 2011-2012 Peter V. Saveliev +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" +Implementation of basic RSA-key digital signature. + +Description: +- Client sends server an Auth message to establish an auth fid. +- Server prepares reads client's public key and generates a random MD5 key + for the signature encrypting it with the key. +- Client decrypts the hash with its public key, signs it, encrypts the + signature and sends it to Server +- Server verifies the signature and allows an 'attach' message from client + +Public keys are, for now, taken from client's ~/.ssh/id_rsa.pub + +This module requires the Python Cryptography Toolkit from +http://www.amk.ca/python/writing/pycrypt/pycrypt.html +""" + +import base64 +import struct +import os +import random +import getpass +import pickle +import Crypto.Util as util +import hashlib +import sys +from . import utils as c9 +from Crypto.Cipher import DES3, AES +from Crypto.PublicKey import RSA, DSA +from Crypto.Util.randpool import RandomPool +from Crypto.Util import number +from Crypto.Hash import MD5 +from binascii import unhexlify + + +class Error(Exception): + pass + + +class AuthError(Error): + pass + + +class AuthsrvError(Error): + pass + + +class BadKeyError(Error): + pass + + +class BadKeyPassword(Error): + pass + + +class ServerError(Error): + pass + + +def gethome(uname): + for x in open('/etc/passwd').readlines(): + u = x.split(':') + if uname == u[0]: + return u[5] + + +def asn1parse(data): + things = [] + while data: + t = ord(data[0]) + assert (t & 0xc0) == 0, 'not a universal value: 0x%02x' % t + #assert t & 0x20, 'not a constructed value: 0x%02x' % t + l = ord(data[1]) + assert data != 0x80, "shouldn't be an indefinite length" + if l & 0x80: # long form + ll = l & 0x7f + l = number.bytes_to_long(data[2:2 + ll]) + s = 2 + ll + else: + s = 2 + body, data = data[s:s + l], data[s + l:] + t = t & (~0x20) + assert t in (SEQUENCE, INTEGER), 'bad type: 0x%02x' % t + if t == SEQUENCE: + things.append(asn1parse(body)) + elif t == INTEGER: + #assert (ord(body[0])&0x80) == 0, "shouldn't have negative number" + things.append(number.bytes_to_long(body)) + if len(things) == 1: + return things[0] + return things + + +def asn1pack(data): + ret = '' + for part in data: + if type(part) in (tuple, list): + partData = asn1pack(part) + partType = SEQUENCE | 0x20 + elif type(part) in (int, long): + partData = number.long_to_bytes(part) + if ord(partData[0]) & (0x80): + partData = '\x00' + partData + partType = INTEGER + else: + raise 'unknown type %s' % type(part) + + ret += chr(partType) + if len(partData) > 127: + l = number.long_to_bytes(len(partData)) + ret += chr(len(l) | 0x80) + l + else: + ret += chr(len(partData)) + ret += partData + return ret + +INTEGER = 0x02 +SEQUENCE = 0x10 + +Length = 1024 + + +def NS(t): + return struct.pack('!L', len(t)) + t + + +def getNS(s, count=1): + ns = [] + c = 0 + for i in range(count): + l, = struct.unpack('!L', s[c:c + 4]) + ns.append(s[c + 4:4 + l + c]) + c += 4 + l + return tuple(ns) + (s[c:],) + + +def MP(number): + if number == 0: + return '\000' * 4 + assert number > 0 + bn = util.number.long_to_bytes(number) + if ord(bn[0]) & 128: + bn = '\000' + bn + return struct.pack('>L', len(bn)) + bn + + +def getMP(data): + """ + get multiple precision integer + """ + length = struct.unpack('>L', data[:4])[0] + return util.number.bytes_to_long(data[4:4 + length]), data[4 + length:] + + +def privkeytostr(key, passphrase=None): + keyData = '-----BEGIN RSA PRIVATE KEY-----\n' + p, q = key.p, key.q + if p > q: + (p, q) = (q, p) + # p is less than q + objData = [0, key.n, key.e, key.d, q, p, key.d % (q - 1), + key.d % (p - 1), util.number.inverse(p, q)] + if passphrase: + iv = RandomPool().get_bytes(8) + hexiv = ''.join(['%02X' % ord(x) for x in iv]) + keyData += 'Proc-Type: 4,ENCRYPTED\n' + keyData += 'DEK-Info: DES-EDE3-CBC,%s\n\n' % hexiv + ba = hashlib.md5(passphrase + iv).digest() + bb = hashlib.md5(ba + passphrase + iv).digest() + encKey = (ba + bb)[:24] + asn1Data = asn1pack([objData]) + if passphrase: + padLen = 8 - (len(asn1Data) % 8) + asn1Data += (chr(padLen) * padLen) + asn1Data = DES3.new(encKey, DES3.MODE_CBC, iv).encrypt(asn1Data) + b64Data = base64.encodestring(asn1Data).replace('\n', '') + b64Data = '\n'.join([b64Data[i:i + 64] for i in + range(0, len(b64Data), 64)]) + keyData += b64Data + '\n' + keyData += '-----END RSA PRIVATE KEY-----' + return keyData + + +def pubkeytostr(key, comment=None): + keyData = MP(key.e) + MP(key.n) + b64Data = base64.encodestring(NS("ssh-rsa") + keyData).replace('\n', '') + return '%s %s %s' % ("ssh-rsa", b64Data, comment) + + +def strtopubkey(data): + d = base64.decodestring(data.split(b' ')[1]) + kind, rest = getNS(d) + if kind == b'ssh-rsa': + e, rest = getMP(rest) + n, rest = getMP(rest) + return RSA.construct((n, e)) + else: + raise Exception('unknown key type %s' % kind) + + +def get_key_data(salt, password, keysize): + keydata = '' + digest = '' + # truncate salt + salt = salt[:8] + while keysize > 0: + hash_obj = MD5.new() + if len(digest) > 0: + hash_obj.update(digest) + hash_obj.update(password) + hash_obj.update(salt) + digest = hash_obj.digest() + size = min(keysize, len(digest)) + keydata += digest[:size] + keysize -= size + return keydata + + +def strtoprivkey(data, password): + kind = data[0][11: 14] + if data[1].startswith('Proc-Type: 4,ENCRYPTED'): # encrypted key + if not password: + raise BadKeyPassword("password required") + enc_type, salt = data[2].split(": ")[1].split(",") + salt = unhexlify(salt.strip()) + b64Data = base64.decodestring(''.join(data[4:-1])) + if enc_type == "DES-EDE3-CBC": + key = get_key_data(salt, password, 24) + keyData = DES3.new(key, DES3.MODE_CBC, salt).decrypt(b64Data) + elif enc_type == "AES-128-CBC": + key = get_key_data(salt, password, 16) + keyData = AES.new(key, AES.MODE_CBC, salt).decrypt(b64Data) + else: + raise BadKeyError("unknown encryption") + removeLen = ord(keyData[-1]) + keyData = keyData[:-removeLen] + else: + keyData = base64.decodestring(''.join(data[1:-1])) + decodedKey = asn1parse(keyData) + if isinstance(decodedKey[0], list): + decodedKey = decodedKey[0] # this happens with encrypted keys + if kind == 'RSA': + n, e, d, p, q = decodedKey[1:6] + return RSA.construct((n, e, d, p, q)) + elif kind == 'DSA': + p, q, g, y, x = decodedKey[1: 6] + return DSA.construct((y, g, p, q, x)) + + +def getprivkey(uname, priv=None, passphrase=None): + if not uname: + raise AuthError("no uname") + + if priv is None: + f = gethome(uname) + if not f: + raise BadKeyError("no home dir for user %s" % uname) + f += '/.ssh/id_rsa' + if not os.path.exists(f): + raise BadKeyError("no private key and no " + f) + else: + privkey = file(f).readlines() + elif not os.path.exists(priv): + raise BadKeyError("file not found: " + priv) + else: + privkey = file(priv).readlines() + + try: + return strtoprivkey(privkey, passphrase) + except BadKeyPassword: + passphrase = getpass.getpass("password: ") + return strtoprivkey(privkey, passphrase) + + +def getchallenge(): + # generate a 16-byte long random string. (note that the built- + # in pseudo-random generator uses a 24-bit seed, so this is not + # as good as it may seem...) + challenge = map(lambda i: c9.bytes3(chr(random.randint(0x20, 0x7e))), range(16)) + return b''.join(challenge) + + +class AuthFs(object): + """ + A special file for performing our pki authentication variant. + On completion of the protocol, suid is set to the authenticated + username. + """ + type = 'pki' + HaveChal, NeedSign, Success = range(3) + cancreate = 0 + pubkeys = {} + + def __init__(self, keys=None): + self.keyfiles = keys or {} + self.pubkeys = {} + + def addpubkeyfromfile(self, uname, pub): + pubkey = file(pub).read() + self.pubkeys[uname] = strtopubkey(pubkey) + + def addpubkey(self, uname, pub): + self.pubkeys[uname] = strtopubkey(pub) + + def delpubkey(self, uname): + if uname in self.pubkeys: + del self.pubkeys[uname] + else: + raise BadKeyError("no key for %s" % uname) + + def getpubkey(self, uname, pub=None): + if not uname: + raise AuthError('no uname') + if uname in self.pubkeys: + return self.pubkeys[uname] + elif pub is None: + f = gethome(uname) + if not f: + raise BadKeyError("no home for user %s" % uname) + f += '/.ssh/id_rsa.pub' + if not os.path.exists(f): + raise BadKeyError("no public key supplied and no " + f) + else: + pubkey = open(f, 'rb').read() + elif not os.path.exists(pub): + raise BadKeyError("file not found: " + pub) + else: + pubkey = open(pub, 'rb').read() + + self.pubkeys[uname] = strtopubkey(pubkey) + return self.pubkeys[uname] + + def estab(self, fid): + fid.suid = None + fid.phase = self.HaveChal + if not hasattr(fid, 'uname'): + raise AuthError("no fid.uname") + uname = fid.uname.decode('utf-8') + fid.key = self.getpubkey(uname, + self.keyfiles.get(uname, None)) + fid.chal = getchallenge() + + def read(self, srv, req): + f = req.fid + if f.phase == self.HaveChal: + f.phase = self.NeedSign + req.ofcall.data = pickle.dumps(f.key.encrypt(f.chal, ''), protocol=2) + srv.respond(req, None) + return + elif f.phase == self.Success: + req.ofcall.data = 'success as ' + f.suid + srv.respond(req, None) + return + raise ServerError("unexpected phase") + + def write(self, srv, req): + f = req.fid + buf = req.ifcall.data + if f.phase == self.NeedSign: + signature = pickle.loads(buf) + if f.key.verify(f.chal, signature): + f.phase = self.Success + f.suid = f.uname + req.ofcall.count = len(buf) + srv.respond(req, None) + return + else: + raise ServerError('signature not verified') + raise ServerError("unexpected phase") + + +def clientAuth(cl, fcall, credentials): + pos = [0] + + def rd(l): + fc = cl._read(fcall.afid, pos[0], l) + pos[0] += len(fc.data) + return fc.data + + def wr(x): + fc = cl._write(fcall.afid, pos[0], x) + pos[0] += fc.count + return fc.count + + c = pickle.loads(rd(2048)) + chal = credentials.key.decrypt(c) + sign = credentials.key.sign(chal, '') + + wr(pickle.dumps(sign, protocol=2)) + return diff --git a/src/py9pfactotum/_vendor/py9p/py9p.py b/src/py9pfactotum/_vendor/py9p/py9p.py new file mode 100644 index 0000000..bad90c5 --- /dev/null +++ b/src/py9pfactotum/_vendor/py9p/py9p.py @@ -0,0 +1,1766 @@ +# Copyright (c) 2008-2011 Tim Newsham, Andrey Mirtchovski +# Copyright (c) 2011-2012 Peter V. Saveliev +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" +9P protocol implementation as documented in plan9 intro(5) and . +""" + +import os +import stat +import sys +import socket +import select +import traceback +import io +import threading +import struct +from . import utils as c9 + +if sys.version_info[0] == 3: + unicode = str + + +IOHDRSZ = 24 +PORT = 564 + +cmdName = {} + + +Tversion = 100 +Rversion = 101 +Tauth = 102 +Rauth = 103 +Tattach = 104 +Rattach = 105 +Terror = 106 +Rerror = 107 +Tflush = 108 +Rflush = 109 +Twalk = 110 +Rwalk = 111 +Topen = 112 +Ropen = 113 +Tcreate = 114 +Rcreate = 115 +Tread = 116 +Rread = 117 +Twrite = 118 +Rwrite = 119 +Tclunk = 120 +Rclunk = 121 +Tremove = 122 +Rremove = 123 +Tstat = 124 +Rstat = 125 +Twstat = 126 +Rwstat = 127 + +for i, k in dict(globals()).items(): + try: + if (i[0] in ('T', 'R')) and isinstance(k, int): + cmdName[k] = i + except: + pass + +version = b'9P2000' +versionu = b'9P2000.u' + +Ebadoffset = "bad offset" +Ebotch = "9P protocol botch" +Ecreatenondir = "create in non-directory" +Edupfid = "duplicate fid" +Eduptag = "duplicate tag" +Eisdir = "is a directory" +Enocreate = "create prohibited" +Enoremove = "remove prohibited" +Enostat = "stat prohibited" +Enotfound = "file not found" +Enowstat = "wstat prohibited" +Eperm = "permission denied" +Eunknownfid = "unknown fid" +Ebaddir = "bad directory in wstat" +Ewalknotdir = "walk in non-directory" +Eopen = "file not open" + +NOTAG = 0xffff +NOFID = 0xffffffff + +# for completeness including all of p9p's defines +OREAD = 0 # open for read +OWRITE = 1 # write +ORDWR = 2 # read and write +OEXEC = 3 # execute, == read but check execute permission +OTRUNC = 16 # or'ed in (except for exec), truncate file first +OCEXEC = 32 # or'ed in, close on exec +ORCLOSE = 64 # or'ed in, remove on close +ODIRECT = 128 # or'ed in, direct access +ONONBLOCK = 256 # or'ed in, non-blocking call +OEXCL = 0x1000 # or'ed in, exclusive use (create only) +OLOCK = 0x2000 # or'ed in, lock after opening +OAPPEND = 0x4000 # or'ed in, append only + +AEXIST = 0 # accessible: exists +AEXEC = 1 # execute access +AWRITE = 2 # write access +AREAD = 4 # read access + +# Qid.type +QTDIR = 0x80 # type bit for directories +QTAPPEND = 0x40 # type bit for append only files +QTEXCL = 0x20 # type bit for exclusive use files +QTMOUNT = 0x10 # type bit for mounted channel +QTAUTH = 0x08 # type bit for authentication file +QTTMP = 0x04 # type bit for non-backed-up file +QTSYMLINK = 0x02 # type bit for symbolic link +QTFILE = 0x00 # type bits for plain file + +# Dir.mode +DMDIR = 0x80000000 # mode bit for directories +DMAPPEND = 0x40000000 # mode bit for append only files +DMEXCL = 0x20000000 # mode bit for exclusive use files +DMMOUNT = 0x10000000 # mode bit for mounted channel +DMAUTH = 0x08000000 # mode bit for authentication file +DMTMP = 0x04000000 # mode bit for non-backed-up file +DMSYMLINK = 0x02000000 # mode bit for symbolic link (Unix, 9P2000.u) +DMDEVICE = 0x00800000 # mode bit for device file (Unix, 9P2000.u) +DMNAMEDPIPE = 0x00200000 # mode bit for named pipe (Unix, 9P2000.u) +DMSOCKET = 0x00100000 # mode bit for socket (Unix, 9P2000.u) +DMSETUID = 0x00080000 # mode bit for setuid (Unix, 9P2000.u) +DMSETGID = 0x00040000 # mode bit for setgid (Unix, 9P2000.u) +DMSTICKY = 0x00010000 # mode bit for sticky bit (Unix, 9P2000.u) + +DMREAD = 0x4 # mode bit for read permission +DMWRITE = 0x2 # mode bit for write permission +DMEXEC = 0x1 # mode bit for execute permission + +ERRUNDEF = 0xFFFFFFFF +UIDUNDEF = 0xFFFFFFFF + +# supported authentication protocols +auths = ['pki', 'sk1'] + + +class Error(Exception): + pass + + +class EofError(Error): + pass + + +class EdupfidError(Error): + pass + + +class RpcError(Error): + pass + + +class ServerError(Error): + pass + + +class ClientError(Error): + pass + + +class VersionError(Error): + pass + + +class Marshal9P(object): + chatty = False + + @property + def length(self): + p = self.buf.tell() + self.buf.seek(0, 2) + l = self.buf.tell() + self.buf.seek(p) + return l + + def enc1(self, x): + """Encode 1-byte unsigned""" + self.buf.write(struct.pack('B', x)) + + def dec1(self): + """Decode 1-byte unsigned""" + return struct.unpack('b', self.buf.read(1))[0] + + def enc2(self, x): + """Encode 2-byte unsigned""" + self.buf.write(struct.pack('H', x)) + + def dec2(self): + """Decode 2-byte unsigned""" + return struct.unpack('H', self.buf.read(2))[0] + + def enc4(self, x): + """Encode 4-byte unsigned""" + self.buf.write(struct.pack('I', x)) + + def dec4(self): + """Decode 4-byte unsigned""" + return struct.unpack('I', self.buf.read(4))[0] + + def enc8(self, x): + """Encode 8-byte unsigned""" + self.buf.write(struct.pack('Q', x)) + + def dec8(self): + """Decode 8-byte unsigned""" + return struct.unpack('Q', self.buf.read(8))[0] + + def encS(self, x): + """Encode data string with 2-byte length""" + self.buf.write(struct.pack("H", len(x))) + if isinstance(x, str) or isinstance(x, unicode): + x = c9.bytes3(x) + self.buf.write(x) + + def decS(self): + """Decode data string with 2-byte length""" + return self.buf.read(self.dec2()) + + def encD(self, d): + """Encode data string with 4-byte length""" + self.buf.write(struct.pack("I", len(d))) + if isinstance(d, str) or isinstance(d, unicode): + d = c9.bytes3(d) + self.buf.write(d) + + def decD(self): + """Decode data string with 4-byte length""" + return self.buf.read(self.dec4()) + + def encF(self, *argv): + """Encode data directly by struct.pack""" + self.buf.write(struct.pack(*argv)) + + def decF(self, fmt, length): + """Decode data by struct.unpack""" + return struct.unpack(fmt, self.buf.read(length)) + + def encQ(self, q): + """Encode Qid structure""" + self.encF("=BIQ", q.type, q.vers, q.path) + + def decQ(self): + """Decode Qid structure""" + return Qid(self.dec1(), self.dec4(), self.dec8()) + + def __init__(self, dotu=0, chatty=False): + self.chatty = chatty + self.dotu = dotu + self._lock = threading.Lock() + self.buf = None + + def _checkType(self, t): + if t not in cmdName: + raise Error("Invalid message type %d" % t) + + def _checkSize(self, v, mask): + if v != v & mask: + raise Error("Invalid value %d" % v) + + def _checkLen(self, x, l): + if len(x) != l: + raise Error("Wrong length %d, expected %d: %r" % ( + len(x), l, x)) + + def setBuffer(self, init=b""): + self.buf = io.BytesIO() + self.buf.write(init) + + def send(self, fd, fcall): + "Format and send a message" + with self._lock: + self.setBuffer(b"0000") + self._checkType(fcall.type) + if self.chatty: + print("-%d-> %s %s %s" % (fd.fileno(), cmdName[fcall.type], \ + fcall.tag, fcall.tostr())) + self.enc(fcall) + self.buf.seek(0) + self.enc4(self.length) + fd.write(self.buf.getvalue()) + + def recv(self, fd): + "Read and decode a message" + with self._lock: + size = struct.unpack("I", fd.read(4))[0] + if size > 0xffffffff or size < 7: + raise Error("Bad message size: %d" % size) + self.setBuffer(fd.read(size - 4)) + self.buf.seek(0) + mtype, tag = self.decF("=BH", 3) + self._checkType(mtype) + fcall = Fcall(mtype, tag) + self.dec(fcall) + # self._checkResid() -- FIXME: check the message residue + if self.chatty: + print("<-%d- %s %s %s" % (fd.fileno(), cmdName[mtype], + tag, fcall.tostr())) + return fcall + + def encstat(self, stats, enclen=1): + statsz = 0 + for x in stats: + if self.dotu: + x.statsz = 61 + \ + len(x.name) + len(x.uid) + len(x.gid) + \ + len(x.muid) + len(x.extension) + statsz += x.statsz + else: + x.statsz = 47 + \ + len(x.name) + len(x.uid) + len(x.gid) + \ + len(x.muid) + statsz += x.statsz + if enclen: + self.enc2(statsz + 2) + + for x in stats: + self.encF("=HHIBIQIIIQ", + x.statsz, x.type, x.dev, x.qid.type, x.qid.vers, + x.qid.path, x.mode, x.atime, x.mtime, x.length) + self.encS(x.name) + self.encS(x.uid) + self.encS(x.gid) + self.encS(x.muid) + if self.dotu: + self.encS(x.extension) + self.encF("=III", + x.uidnum, x.gidnum, x.muidnum) + + def enc(self, fcall): + self.encF("=BH", fcall.type, fcall.tag) + if fcall.type in (Tversion, Rversion): + self.encF("I", fcall.msize) + self.encS(fcall.version) + elif fcall.type == Tauth: + self.encF("I", fcall.afid) + self.encS(fcall.uname) + self.encS(fcall.aname) + if self.dotu: + self.encF("I", fcall.uidnum) + elif fcall.type == Rauth: + self.encQ(fcall.aqid) + elif fcall.type == Rerror: + self.encS(fcall.ename) + if self.dotu: + self.encF("I", fcall.errno) + elif fcall.type == Tflush: + self.encF("H", fcall.oldtag) + elif fcall.type == Tattach: + self.encF("=II", fcall.fid, fcall.afid) + self.encS(fcall.uname) + self.encS(fcall.aname) + if self.dotu: + self.encF("I", fcall.uidnum) + elif fcall.type == Rattach: + self.encQ(fcall.qid) + elif fcall.type == Twalk: + self.encF("=IIH", fcall.fid, fcall.newfid, + len(fcall.wname)) + for x in fcall.wname: + self.encS(x) + elif fcall.type == Rwalk: + self.encF("H", len(fcall.wqid)) + for x in fcall.wqid: + self.encQ(x) + elif fcall.type == Topen: + self.encF("=IB", fcall.fid, fcall.mode) + elif fcall.type in (Ropen, Rcreate): + self.encQ(fcall.qid) + self.encF("I", fcall.iounit) + elif fcall.type == Tcreate: + self.encF("I", fcall.fid) + self.encS(fcall.name) + self.encF("=IB", fcall.perm, fcall.mode) + if self.dotu: + self.encS(fcall.extension) + elif fcall.type == Tread: + self.encF("=IQI", fcall.fid, fcall.offset, + fcall.count) + elif fcall.type == Rread: + self.encD(fcall.data) + elif fcall.type == Twrite: + self.encF("=IQI", fcall.fid, fcall.offset, + len(fcall.data)) + self.buf.write(fcall.data) + elif fcall.type == Rwrite: + self.encF("I", fcall.count) + elif fcall.type in (Tclunk, Tremove, Tstat): + self.encF("I", fcall.fid) + elif fcall.type in (Rstat, Twstat): + if fcall.type == Twstat: + self.encF("I", fcall.fid) + self.encstat(fcall.stat, 1) + + def decstat(self, stats, enclen=0): + if enclen: + # feed 2 bytes of total size + self.buf.read(2) + while self.buf.tell() < self.length: + self.buf.read(2) + + s = Dir(self.dotu) + (s.type, + s.dev, + typ, vers, path, + s.mode, + s.atime, + s.mtime, + s.length) = self.decF("=HIBIQIIIQ", 39) + s.qid = Qid(typ, vers, path) + s.name = self.decS() # name + s.uid = self.decS() # uid + s.gid = self.decS() # gid + s.muid = self.decS() # muid + if self.dotu: + s.extension = self.decS() + (s.uidnum, + s.gidnum, + s.muidnum) = self.decF("=III", 12) + stats.append(s) + + def dec(self, fcall): + if fcall.type in (Tversion, Rversion): + fcall.msize = self.dec4() + fcall.version = self.decS() + elif fcall.type == Tauth: + fcall.afid = self.dec4() + fcall.uname = self.decS() + fcall.aname = self.decS() + if self.dotu: + fcall.uidnum = self.dec4() + elif fcall.type == Rauth: + fcall.aqid = self.decQ() + elif fcall.type == Rerror: + fcall.ename = self.decS() + if self.dotu: + fcall.errno = self.dec4() + elif fcall.type == Tflush: + fcall.oldtag = self.dec2() + elif fcall.type == Tattach: + fcall.fid = self.dec4() + fcall.afid = self.dec4() + fcall.uname = self.decS() + fcall.aname = self.decS() + if self.dotu: + fcall.uidnum = self.dec4() + elif fcall.type == Rattach: + fcall.qid = self.decQ() + elif fcall.type == Twalk: + fcall.fid = self.dec4() + fcall.newfid = self.dec4() + fcall.nwname = self.dec2() + fcall.wname = [self.decS().decode('utf-8') for n in range(fcall.nwname)] + elif fcall.type == Rwalk: + fcall.nwqid = self.dec2() + fcall.wqid = [self.decQ() for n in range(fcall.nwqid)] + elif fcall.type == Topen: + fcall.fid = self.dec4() + fcall.mode = self.dec1() + elif fcall.type in (Ropen, Rcreate): + fcall.qid = self.decQ() + fcall.iounit = self.dec4() + elif fcall.type == Tcreate: + fcall.fid = self.dec4() + fcall.name = self.decS().decode('utf-8') + fcall.perm = self.dec4() + fcall.mode = self.dec1() + if self.dotu: + fcall.extension = self.decS().decode('utf-8') + elif fcall.type == Tread: + fcall.fid = self.dec4() + fcall.offset = self.dec8() + fcall.count = self.dec4() + elif fcall.type == Rread: + fcall.data = self.decD() + elif fcall.type == Twrite: + fcall.fid = self.dec4() + fcall.offset = self.dec8() + fcall.count = self.dec4() + fcall.data = self.buf.read(fcall.count) + elif fcall.type == Rwrite: + fcall.count = self.dec4() + elif fcall.type in (Tclunk, Tremove, Tstat): + fcall.fid = self.dec4() + elif fcall.type in (Rstat, Twstat): + if fcall.type == Twstat: + fcall.fid = self.dec4() + self.decstat(fcall.stat, 1) + + return fcall + + +def modetostr(mode): + bits = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"] + + def b(s): + return bits[(mode >> s) & 7] + d = "-" + if mode & DMDIR: + d = "d" + elif mode & DMAPPEND: + d = "a" + return "%s%s%s%s" % (d, b(6), b(3), b(0)) + + +def open2stat(mode): + return (mode & 3) |\ + ((mode & OAPPEND) >> 4) |\ + ((mode & OEXCL) >> 5) |\ + ((mode & OTRUNC) << 5) + + +def open2plan(mode): + return (mode & 3) |\ + ((mode & os.O_APPEND) << 4) |\ + ((mode & os.O_EXCL) << 5) |\ + ((mode & os.O_TRUNC) >> 5) + + +def mode2stat(mode): + return (mode & 0o777) |\ + ((mode & DMDIR ^ DMDIR) >> 16) |\ + ((mode & DMDIR) >> 17) |\ + ((mode & DMSYMLINK) >> 10) |\ + ((mode & DMSYMLINK) >> 12) |\ + ((mode & DMSETUID) >> 8) |\ + ((mode & DMSETGID) >> 8) |\ + ((mode & DMSTICKY) >> 7) + + +def mode2plan(mode): + return (mode & 0o777) | \ + ((mode & stat.S_IFDIR) << 17) |\ + ((mode & stat.S_ISUID) << 8) |\ + ((mode & stat.S_ISGID) << 8) |\ + ((mode & stat.S_ISVTX) << 7) |\ + (int(mode == stat.S_IFLNK) << 25) + + +def hash8(obj): + return int(abs(hash(obj))) + + +def otoa(p): + '''Convert from open() to access()-style args''' + ret = 0 + + np = p & 3 + if np == OREAD: + ret = AREAD + elif np == OWRITE: + ret = AWRITE + elif np == ORDWR: + ret = AREAD | AWRITE + elif np == OEXEC: + ret = AEXEC + + if(p & OTRUNC): + ret |= AWRITE + + return ret + + +def hasperm(f, uid, p): + '''Verify permissions for access type 'p' to file 'f'. 'p' is of the type + returned by otoa() above, i.e., should contain the A* flags. + + f should resemble Dir, i.e., should have f.mode, f.uid, f.gid''' + m = f.mode & 7 # other + if (p & m) == p: + return 1 + + if f.uid == uid: + m |= (f.mode >> 6) & 7 + if (p & m) == p: + return 1 + if f.gid == uid: + m |= (f.mode >> 3) & 7 + if (p & m) == p: + return 1 + return 0 + + +class Sock(object): + """Per-connection state and appropriate read and write methods + for the Marshaller.""" + + def __init__(self, sock, dotu=0, chatty=0): + self.sock = sock + self.fids = {} # fids are per client + self.reqs = {} # reqs are per client + self.uname = None + self.closing = False + self.marshal = Marshal9P(dotu=dotu, chatty=chatty) + + def send(self, x): + self.marshal.send(self, x) + + def recv(self): + return self.marshal.recv(self) + + def read(self, l): + if self.closing: + return "" + x = self.sock.recv(l) + while len(x) < l: + b = self.sock.recv(l - len(x)) + if not b: + raise EofError("client eof") + x += b + return x + + def write(self, buf): + if self.closing: + return len(buf) + if self.sock.send(buf) != len(buf): + raise Error("short write") + + def fileno(self): + return self.sock.fileno() + + def delfid(self, fid): + if fid in self.fids: + self.fids[fid].ref = self.fids[fid].ref - 1 + if self.fids[fid].ref == 0: + del self.fids[fid] + + def getfid(self, fid): + if fid in self.fids: + return self.fids[fid] + return None + + def close(self): + self.sock.close() + + +class Fcall(object): + '''# possible values, from p9p's fcall.h + msize # Tversion, Rversion + version # Tversion, Rversion + oldtag # Tflush + ename # Rerror + qid # Rattach, Ropen, Rcreate + iounit # Ropen, Rcreate + aqid # Rauth + afid # Tauth, Tattach + uname # Tauth, Tattach + aname # Tauth, Tattach + perm # Tcreate + name # Tcreate + mode # Tcreate, Topen + newfid # Twalk + nwname # Twalk + wname # Twalk, array + nwqid # Rwalk + wqid # Rwalk, array + offset # Tread, Twrite + count # Tread, Twrite, Rread + data # Twrite, Rread + nstat # Twstat, Rstat + stat # Twstat, Rstat + + # dotu extensions: + errno # Rerror + extension # Tcreate + ''' + + def __init__(self, ftype, tag=1, fid=None): + self.type = ftype + self.fid = fid + self.tag = tag + self.stat = [] + self.iounit = 8192 + self.ename = None + self.wqid = None + + def tostr(self): + attr = [x for x in dir(self) if not x.startswith('_') and + not x.startswith('tostr')] + + ret = ' '.join("%s=%s" % (x, getattr(self, x)) for x in attr) + ret = cmdName[self.type] + " " + ret + + return repr(ret) + + +class Qid(object): + + def __init__(self, qtype=None, vers=None, path=None): + self.type = qtype + self.vers = vers + self.path = path + + def __str__(self): + return '(%x,%x,%x)' % (self.type, self.vers, self.path) + + __repr__ = __str__ + + +class Fid(object): + + def __init__(self, pool, fid, path='', auth=0): + if fid in pool: + raise EdupfidError(Edupfid) + self.fid = fid + self.ref = 1 + self.omode = -1 + self.auth = auth + self.uid = None + self.qid = None + self.path = path + + pool[fid] = self + + +""" + # type: server type + # dev server subtype + # + # file data: + # qid unique id from server + # mode permissions + # atime last read time + # mtime last write time + # length file length + # name + # uid owner name + # gid group name + # muid last modifier name + # + # 9P2000.u extensions: + # uidnum numeric uid + # gidnum numeric gid + # muidnum numeric muid + # *ext extended info +""" +class Dir(object): + + def __init__(self, dotu=0, *args, **kwargs): + self.dotu = dotu + self.statsz = 0 + # the dotu arguments will be added separately. this is not + # straightforward but is cleaner. + if len(args): + (self.type, + self.dev, + self.qid, + self.mode, + self.atime, + self.mtime, + self.length, + self.name, + self.uid, + self.gid, + self.muid) = args[:11] + + if dotu: + (self.extension, + self.uidnum, + self.gidnum, + self.muidnum) = args[11:15] + + if len(kwargs.keys()): + for i in kwargs.keys(): + setattr(self, i, kwargs[i]) + + if not dotu: + (self.extension, + self.uidnum, + self.gidnum, + self.muidnum) = "", UIDUNDEF, UIDUNDEF, UIDUNDEF + + def tolstr(self, dirname=''): + if dirname != '': + dirname = dirname + '/' + if self.dotu: + return "%s %d %d %-8d\t\t%s%s" % ( + modetostr(self.mode), self.uidnum, self.gidnum, + self.length, dirname, self.name) + else: + return "%s %s %s %-8d\t\t%s%s" % ( + modetostr(self.mode), self.uid, self.gid, + self.length, dirname, self.name) + + def todata(self, marsh): + marsh.setBuffer() + marsh.encstat((self, ), 0) + return marsh.buf.getvalue() + + +class Req(object): + def __init__(self, tag, fd=None, ifcall=None, ofcall=None, + dir=None, oldreq=None, fid=None, afid=None, newfid=None): + self.tag = tag + self.fd = fd + self.ifcall = ifcall + self.ofcall = ofcall + self.dir = dir + self.oldreq = oldreq + self.fid = fid + self.afid = afid + self.newfid = newfid + + +class Server(object): + """ + A server interface to the protocol. + Subclass this to provide service + """ + chatty = False + readpool = [] + writepool = [] + activesocks = {} + + def __init__(self, listen, authmode=None, fs=None, user=None, + dom=None, key=None, chatty=False, dotu=False, msize=8192): + self.msize = msize + + if authmode is None: + self.authfs = None + elif authmode == 'pki': + from py9p import pki + self.authfs = pki.AuthFs(key) + else: + raise ServerError("unsupported auth mode") + + self.fs = fs + self.authmode = authmode + self.dotu = dotu + + self.readpool = [] + self.writepool = [] + self.deferread = {} + self.deferwrite = {} + self.user = user + self.dom = dom + self.host = listen[0] + self.port = listen[1] + self.chatty = chatty + + if self.host[0] == '/': + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + os.unlink(self.host) + except OSError: + pass + self.sock.bind(self.host) + os.chmod(self.host, self.port) + else: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind((self.host, self.port),) + self.sock.listen(5) + self.readpool.append(self.sock) + if self.chatty: + print("listening to %s:%d" % (self.host, self.port)) + + def mount(self, fs): + # XXX: for now only allow one mount + # in the future accept fs/root and + # handle different filesystems at walk time + self.fs = fs + + def shutdown(self, sock): + """Close down a connection.""" + if sock not in self.activesocks: + return + s = self.activesocks[sock] + assert not s.closing # we looped! + s.closing = True + + if sock in self.readpool: + self.readpool.remove(sock) + if sock in self.writepool: + self.writepool.remove(sock) + + # find first tag not in use + tags = [r.ifcall.tag for r in s.reqs] + tag = [n for n in range(1, 65535) if n not in tags][0] + + # flush all outstanding requests + for r in s.reqs: + req = Req(tag) + req.ifcall = Fcall(Tflush, tag=tag, oldtag=r.ifcall.tag) + req.ofcall = Fcall(Rflush, tag=tag) + req.fd = s.fileno() + req.sock = s + self.tflush(req) + + # clunk all open fids + fids = list(s.fids.keys()) + for fid in fids: + req = Req(tag) + req.ifcall = Fcall(Tclunk, tag=tag, fid=fid) + req.ofcall = Fcall(Rclunk, tag=tag) + req.fd = s.fileno() + req.sock = s + self.tclunk(req) + + # flush should have taken care of this + assert sock not in self.deferwrite and sock not in self.deferread + + sock.close() + del self.activesocks[sock] + + def serve(self): + while len(self.readpool) > 0 or len(self.writepool) > 0: + inr, outr, excr = select.select(self.readpool, self.writepool, []) + for s in outr: + if s in self.deferwrite: + # this is a fs-delayed req that's just become ready, + req = self.deferwrite[s] + self.unregwritefd(s) + name = cmdName[req.ifcall.type][1:] + try: + func = getattr(self.fs, name) + func(self, req) + except: + print("error in delayed write response: %s") + traceback.print_exc() + self.respond(req, "error in delayed response") + continue + for s in inr: + if s == self.sock: + cl, addr = s.accept() + self.readpool.append(cl) + self.activesocks[cl] = Sock(cl, self.dotu, self.chatty) + if self.chatty: + print("accepted connection from: %s" % str(addr)) + else: + if s in self.deferread: + # this is a fs-delayed req that's just become ready, + req = self.deferread[s] + self.unregreadfd(s) + name = cmdName[req.ifcall.type][1:] + try: + func = getattr(self.fs, name) + func(self, req) + except: + print("error in delayed read response: %s") + traceback.print_exc() + self.respond(req, "error in delayed response") + continue + try: + self.fromnet(self.activesocks[s]) + except socket.error as e: + if self.chatty: + print("socket error: %s" % (e.args[1])) + traceback.print_exc() + self.shutdown(s) + except EofError as e: + if self.chatty: + print("socket closed: %s" % (e.args[0])) + self.readpool.remove(s) + self.shutdown(s) + except Exception as e: + print("error in fromnet (protocol botch?)") + traceback.print_exc() + print("dropping connection...") + self.shutdown(s) + + if self.chatty: + print("main socket closed") + + return + + def respond(self, req, error=None, errno=None): + name = 'r' + cmdName[req.ifcall.type][1:] + if hasattr(self, name): + func = getattr(self, name) + try: + func(req, error) + except Exception as e: + print("error in respond: ") + traceback.print_exc() + return -1 + else: + raise ServerError("can not handle message type " + + cmdName[req.ifcall.type]) + + req.ofcall.tag = req.ifcall.tag + if error: + req.ofcall.type = Rerror + req.ofcall.ename = c9.bytes3(error) + if not errno: + errno = ERRUNDEF + req.ofcall.errno = errno + s = req.sock + try: + s.send(req.ofcall) + except socket.error as e: + if self.chatty: + print("socket error: %s" % (e.args[1])) + traceback.print_exc() + self.shutdown(s) + except EofError as e: + if self.chatty: + print("socket closed: %s" % (e.args[0])) + self.shutdown(s) + except Exception as e: + if self.chatty: + print("socket error: %s" % (str(e.args))) + traceback.print_exc() + self.shutdown(s) + + # XXX: unsure whether we need proper flushing semantics from rsc's p9p + # thing is, we're not threaded. + + def fromnet(self, fd): + fcall = fd.recv() + req = Req(fcall.tag) + req.ifcall = fcall + req.ofcall = Fcall(fcall.type + 1, fcall.tag) + req.fd = fd.fileno() + req.sock = fd + + if req.ifcall.type not in cmdName: + self.respond(req, "invalid message") + + name = "t" + cmdName[req.ifcall.type][1:] + if hasattr(self, name): + func = getattr(self, name) + try: + func(req) + except Error as e: + if self.chatty: + traceback.print_exc() + self.respond(req, str(e.args[0][1]), e.args[0][0]) + except Exception as e: + if self.chatty: + traceback.print_exc() + self.respond(req, 'unhandled internal exception: ' + + str(e.args[0])) + else: + self.respond(req, "unhandled message: %s" % ( + cmdName[req.ifcall.type])) + return + + def regreadfd(self, fd, req): + '''Register a file descriptor in the read pool. When a fileserver + wants to delay responding to a message they can register an fd and + have it polled for reading. When it's ready, the corresponding 'req' + will be called''' + self.deferread[fd] = req + self.readpool.append(fd) + + def regwritefd(self, fd, req): + '''Register a file descriptor in the write pool.''' + self.deferwrite[fd] = req + self.writepool.append(fd) + + def unregreadfd(self, fd): + '''Delete a fd registered with regreadfd().''' + del self.deferread[fd] + self.readpool.remove(fd) + + def unregwritefd(self, fd): + '''Delete a fd registered with regwritefd().''' + del self.deferwrite[fd] + self.writepool.remove(fd) + + def tversion(self, req): + if req.ifcall.version[0:2] != b'9P': + req.ofcall.version = "unknown" + self.respond(req, None) + return + + if req.ifcall.version == versionu: + # dotu is passed to server init to indicate whether dotu + # will be supported + # + # if the server init code was told not to implement dotu + # then even if the remote wants dotu we must fall back to 9P2000 + if self.dotu: + req.ofcall.version = versionu + else: + req.ofcall.version = version + else: + # if somebody requested a later version of the protocol + # (9Pxxxx.y, for xxxx>2000) then fall back to what we know + # best: 9P2000; + # + # if somebody requested 9Pxxxx for xxxx<2000 then we have no + # clue what to say and we just keep repeating the same. + req.ofcall.version = version + req.sock.marshal.dotu = 0 + + req.ofcall.msize = min(req.ifcall.msize, self.msize) + self.respond(req, None) + + def rversion(self, req, error): + # self.msize = req.ofcall.msize + pass + + def tauth(self, req): + if self.authfs is None: + self.respond(req, "%s: authentication not required" % + (sys.argv[0])) + return + + try: + req.afid = Fid(req.sock.fids, req.ifcall.afid, auth=1) + except EdupfidError: + self.respond(req, Edupfid) + return + req.afid.uname = req.ifcall.uname + self.authfs.estab(req.afid) + req.afid.qid = Qid(QTAUTH, 0, hash8('#a')) + req.ofcall.aqid = req.afid.qid + self.respond(req, None) + + def rauth(self, req, error): + if error and req.afid: + req.sock.delfid(req.afid.fid) + + def tattach(self, req): + try: + req.fid = Fid(req.sock.fids, req.ifcall.fid) + except EdupfidError: + self.respond(req, Edupfid) + return + + req.afid = None + if req.ifcall.afid != NOFID: + req.afid = req.sock.fids[req.ifcall.afid] + if not req.afid: + self.respond(req, Eunknownfid) + return + if req.afid.suid != req.ifcall.uname: + self.respond(req, "not authenticated as %r" % req.ifcall.uname) + return + elif self.chatty: + print("authenticated as %r" % req.ifcall.uname) + elif self.authmode is not None: + self.respond(req, 'authentication not complete') + + req.fid.uid = req.ifcall.uname + req.sock.uname = req.ifcall.uname # now we know who we are + if hasattr(self.fs, 'attach'): + self.fs.attach() + else: + req.ofcall.qid = self.fs.root.qid + req.fid.qid = self.fs.root.qid + self.respond(req, None) + return + + def rattach(self, req, error): + if error and req.fid: + req.sock.delfid(req.fid.fid) + + def tflush(self, req): + if hasattr(self.fs, 'flush'): + self.fs.flush(self, req) + else: + req.sock.reqs = [] + self.respond(req, None) + + def rflush(self, req, error): + if req.oldreq: + if req.oldreq.responded == 0: + req.oldreq.nflush = req.oldreq.nflush + 1 + if not hasattr(req.oldreq, 'flush'): + req.oldreq.nflush = 0 + req.oldreq.flush = [] + req.oldreq.nflush = req.oldreq.nflush + 1 + req.oldreq.flush.append(req) + req.oldreq = None + return 0 + + def twalk(self, req): + req.ofcall.wqid = [] + + req.fid = req.sock.getfid(req.ifcall.fid) + if not req.fid: + self.respond(req, Eunknownfid) + return + if req.fid.omode != -1: + self.respond(req, "cannot clone open fid") + return + if len(req.ifcall.wname) and not (req.fid.qid.type & QTDIR): + self.respond(req, Ewalknotdir) + return + if req.ifcall.fid != req.ifcall.newfid: + try: + req.newfid = Fid(req.sock.fids, req.ifcall.newfid) + except EdupfidError: + self.respond(req, Edupfid) + return + req.newfid.uid = req.fid.uid + else: + req.fid.ref = req.fid.ref + 1 + req.newfid = req.fid + + if len(req.ifcall.wname) == 0: + req.ofcall.nwqid = 0 + self.respond(req, None) + elif hasattr(self.fs, 'walk'): + self.fs.walk(self, req) + else: + self.respond(req, "no walk function") + + def rwalk(self, req, error): + if error or (len(req.ofcall.wqid) < len(req.ifcall.wname) and + len(req.ifcall.wname) > 0): + if req.ifcall.fid != req.ifcall.newfid and req.newfid: + req.sock.delfid(req.ifcall.newfid) + if len(req.ofcall.wqid) == 0: + if not error and len(req.ifcall.wname) != 0: + req.error = Enotfound + else: + req.error = None + else: + if len(req.ofcall.wqid) == 0: + req.newfid.qid = req.fid.qid + else: + req.newfid.qid = req.ofcall.wqid[-1] + + def topen(self, req): + req.fid = req.sock.getfid(req.ifcall.fid) + if not req.fid: + self.respond(req, Eunknownfid) + return + if req.fid.omode != -1: + self.respond(req, Ebotch) + return + if req.fid.qid.type & QTDIR: + if (req.ifcall.mode & (~ORCLOSE)) != OREAD: + self.respond(req, Eisdir) + return + # repeating the same bug as p9p? + if otoa(req.ifcall.mode) != AREAD: + self.respond(req, Eisdir) + return + + req.ofcall.qid = req.fid.qid + req.ofcall.iounit = self.msize - IOHDRSZ + req.ifcall.acc = [AREAD, AWRITE, + AREAD | AWRITE, AEXEC][req.ifcall.mode & 3] + if req.ifcall.mode & OTRUNC: + req.ifcall.acc |= AWRITE + + if (req.fid.qid.type & QTDIR) and (req.ifcall.acc != AREAD): + self.respond(req, Eperm) + if hasattr(self.fs, 'open'): + self.fs.open(self, req) + else: + self.respond(req, None) + + def ropen(self, req, error): + if error: + return + req.fid.omode = req.ifcall.mode + req.fid.qid = req.ofcall.qid + if req.ofcall.qid.type & QTDIR: + req.fid.diroffset = 0 + + def tcreate(self, req): + req.fid = req.sock.getfid(req.ifcall.fid) + if not req.fid: + self.respond(req, Eunknownfid) + elif req.fid.omode != -1: + self.respond(req, Ebotch) + elif not (req.fid.qid.type & QTDIR): + self.respond(req, Ecreatenondir) + elif hasattr(self.fs, 'create'): + self.fs.create(self, req) + else: + self.respond(req, Enocreate) + + def rcreate(self, req, error): + if error: + return + req.fid.omode = req.ifcall.mode + req.fid.qid = req.ofcall.qid + req.ofcall.iounit = self.msize - IOHDRSZ + + def bufread(self, req, buf): + req.ofcall.data = buf[req.ifcall.offset: req.ifcall.offset + + req.ifcall.count] + return self.respond(req, None) + + def tread(self, req): + req.fid = req.sock.getfid(req.ifcall.fid) + if not req.fid: + return self.respond(req, Eunknownfid) + if req.ifcall.count < 0: + return self.respond(req, Ebotch) + if req.ifcall.offset < 0 or ((req.fid.qid.type & QTDIR) and + (req.ifcall.offset != 0) and + (req.ifcall.offset != req.fid.diroffset)): + return self.respond(req, Ebadoffset) + if req.fid.qid.type & QTAUTH and self.authfs: + self.authfs.read(self, req) + return + # auth Tread goes w/o omode, there was no open() + if req.fid.omode == -1: + return self.respond(req, Eopen) + + if req.ifcall.count > self.msize - IOHDRSZ: + req.ifcall.count = self.msize - IOHDRSZ + o = req.fid.omode & 3 + if o != OREAD and o != ORDWR and o != OEXEC: + return self.respond(req, Ebotch) + if hasattr(self.fs, 'read'): + self.fs.read(self, req) + else: + self.respond(req, 'no server read function') + + def rread(self, req, error): + if error: + return + + if req.fid.qid.type & QTDIR: + data = b"" + for x in req.ofcall.stat: + ndata = x.todata(req.sock.marshal) + if (len(data) - req.ifcall.offset) + \ + len(ndata) < req.ifcall.count: + data = data + ndata + else: + break + req.ofcall.data = data[req.ifcall.offset:] + req.fid.diroffset = req.ifcall.offset + len(req.ofcall.data) + + def twrite(self, req): + req.fid = req.sock.getfid(req.ifcall.fid) + if not req.fid: + return self.respond(req, Eunknownfid) + if req.ifcall.count < 0 or req.ifcall.offset < 0: + return self.respond(req, Ebotch) + if req.fid.qid.type & QTAUTH and self.authfs: + self.authfs.write(self, req) + return + # auth Tread goes w/o omode, there was no open() + if req.fid.omode == -1: + return self.respond(req, Eopen) + + if req.ifcall.count > self.msize - IOHDRSZ: + req.ifcall.count = self.msize - IOHDRSZ + o = req.fid.omode & 3 + if o != OWRITE and o != ORDWR: + return self.respond(req, + "write on fid with open mode 0x%ux" % req.fid.omode) + if hasattr(self.fs, 'write'): + self.fs.write(self, req) + else: + self.respond(req, 'no server write function') + + def rwrite(self, req, error): + return + + def tclunk(self, req): + req.fid = req.sock.getfid(req.ifcall.fid) + if not req.fid: + return self.respond(req, Eunknownfid) + if hasattr(self.fs, 'clunk') and not (req.fid.qid.type & QTAUTH): + self.fs.clunk(self, req) + else: + self.respond(req, None) + req.sock.delfid(req.ifcall.fid) + + def rclunk(self, req, error): + return + + def tremove(self, req): + req.fid = req.sock.getfid(req.ifcall.fid) + if not req.fid: + return self.respond(req, Eunknownfid) + if hasattr(self.fs, 'remove'): + self.fs.remove(self, req) + else: + self.respond(req, Enoremove) + + def rremove(self, req, error): + req.sock.delfid(req.ifcall.fid) + return + + def tstat(self, req): + req.fid = req.sock.getfid(req.ifcall.fid) + req.ofcall.stat = [] + if not req.fid: + return self.respond(req, Eunknownfid) + if hasattr(self.fs, 'stat'): + self.fs.stat(self, req) + else: + self.respond(req, Enostat) + + def rstat(self, req, error): + if error: + return + + def twstat(self, req): + req.fid = req.sock.getfid(req.ifcall.fid) + if not req.fid: + return self.respond(req, Eunknownfid) + if hasattr(self.fs, 'wstat'): + self.fs.wstat(self, req) + else: + self.respond(req, Enowstat) + + def rwstat(self, req, error): + return + + +class Credentials(object): + def __init__(self, user, authmode=None, passwd=None, + keyfile=None, key=None): + self.user = c9.bytes3(user) if isinstance(user, str) else user + self.passwd = c9.bytes3(passwd) if isinstance(passwd, str) else passwd + self.key = key + self.authmode = authmode + if self.authmode == "pki": + import pki + self.key = pki.getprivkey(user, keyfile, passwd) + + +class Client(object): + """ + A client interface to the protocol. + """ + AFID = 10 + ROOT = 11 + CWD = 12 + F = 13 + + path = '' # for 'getwd' equivalent + + def __init__(self, fd, credentials, authsrv=None, chatty=0, dotu=0, + msize=8192): + self.credentials = credentials + self.dotu = dotu + self.msize = msize + self.fd = Sock(fd, dotu, chatty) + self.login(authsrv, credentials) + + def _rpc(self, fcall): + if fcall.type == Tversion: + fcall.tag = NOTAG + self.fd.send(fcall) + try: + ifcall = self.fd.recv() + except (KeyboardInterrupt, Exception): + # try to flush the operation, then rethrow exception + if fcall.type != Tflush: + try: + self._flush(fcall.tag, fcall.tag + 1) + except Exception: + pass + raise + if ifcall.tag != fcall.tag: + raise RpcError("invalid tag received") + if ifcall.type == Rerror: + raise RpcError(ifcall.ename) + if ifcall.type != fcall.type + 1: + raise ClientError("incorrect reply from server: %r" % + [fcall.type, fcall.tag]) + return ifcall + + # protocol calls; part of 9p + # should be private functions, really + def _version(self, msize, version): + fcall = Fcall(Tversion) + self.msize = msize + fcall.msize = msize + fcall.version = version + return self._rpc(fcall) + + def _auth(self, afid, uname, aname): + fcall = Fcall(Tauth) + fcall.afid = afid + fcall.uname = uname + fcall.aname = aname + fcall.uidnum = 0 + return self._rpc(fcall) + + def _attach(self, fid, afid, uname, aname): + fcall = Fcall(Tattach) + fcall.fid = fid + fcall.afid = afid + fcall.uname = uname + fcall.aname = aname + fcall.uidnum = 0 + return self._rpc(fcall) + + def _walk(self, fid, newfid, wnames): + fcall = Fcall(Twalk) + fcall.fid = fid + fcall.newfid = newfid + fcall.wname = [c9.bytes3(x) for x in wnames] + return self._rpc(fcall) + + def _open(self, fid, mode): + fcall = Fcall(Topen) + fcall.fid = fid + fcall.mode = mode + return self._rpc(fcall) + + def _create(self, fid, name, perm, mode, extension=b""): + fcall = Fcall(Tcreate) + fcall.fid = fid + fcall.name = name + fcall.perm = perm + fcall.mode = mode + fcall.extension = extension + return self._rpc(fcall) + + def _read(self, fid, off, count): + fcall = Fcall(Tread) + fcall.fid = fid + fcall.offset = off + if count > self.msize - IOHDRSZ: + count = self.msize - IOHDRSZ + fcall.count = count + return self._rpc(fcall) + + def _write(self, fid, off, data): + fcall = Fcall(Twrite) + fcall.fid = fid + fcall.offset = off + fcall.data = data + return self._rpc(fcall) + + def _clunk(self, fid): + fcall = Fcall(Tclunk) + fcall.fid = fid + return self._rpc(fcall) + + def _remove(self, fid): + fcall = Fcall(Tremove) + fcall.fid = fid + return self._rpc(fcall) + + def _stat(self, fid): + fcall = Fcall(Tstat) + fcall.fid = fid + return self._rpc(fcall) + + def _wstat(self, fid, stats): + fcall = Fcall(Twstat) + fcall.fid = fid + fcall.stat = stats + return self._rpc(fcall) + + def _flush(self, tag, oldtag): + fcall = Fcall(Tflush, tag=tag) + fcall.oldtag = tag + return self._rpc(fcall) + + def _fullclose(self): + self._clunk(self.ROOT) + self._clunk(self.CWD) + self.fd.close() + + def login(self, authsrv, credentials): + if self.dotu: + ver = versionu + else: + ver = version + fcall = self._version(self.msize, ver) + self.msize = fcall.msize + if fcall.version != ver: + raise VersionError("version mismatch: %r" % fcall.version) + + fcall.afid = self.AFID + try: + rfcall = self._auth(fcall.afid, credentials.user, b'') + except RpcError as e: + fcall.afid = NOFID + + if fcall.afid != NOFID: + fcall.aqid = rfcall.aqid + + if credentials.authmode is None: + raise ClientError('no authentication method') + elif credentials.authmode == 'pki': + import pki + pki.clientAuth(self, fcall, credentials) + else: + raise ClientError('unknown authentication method: %s' % + credentials.authmode) + + self._attach(self.ROOT, fcall.afid, credentials.user, b'') + if fcall.afid != NOFID: + self._clunk(fcall.afid) + self._walk(self.ROOT, self.CWD, []) + self.path = '/' + + # user accessible calls, the actual implementation of a client + def close(self): + self._clunk(self.F) + + def walk(self, pstr=''): + root = self.CWD + if pstr == '': + path = [] + elif pstr.find('/') == -1: + path = [pstr] + else: + path = pstr.split('/') + if path[0] == '': + root = self.ROOT + path = path[1:] + path = list(filter(None, path)) + try: + fcall = self._walk(root, self.F, path) + except RpcError: + raise + + if len(fcall.wqid) < len(path): + raise RpcError('incomplete walk (%d out of %d)' % + (len(fcall.wqid), len(path))) + return fcall.wqid + + def open(self, pstr='', mode=0): + if self.walk(pstr) is None: + return + self.pos = 0 + try: + fcall = self._open(self.F, mode) + except RpcError: + self.close() + raise + return fcall + + def create(self, pstr, perm=0o644, mode=1): + p = pstr.split('/') + pstr2, name = '/'.join(p[:-1]), p[-1] + if self.walk(pstr2) is None: + return + self.pos = 0 + try: + return self._create(self.F, name, perm, mode) + except RpcError: + self.close() + raise + + def rm(self, pstr): + self.open(pstr) + try: + self._remove(self.F) + except RpcError: + raise + + def read(self, l): + try: + fcall = self._read(self.F, self.pos, l) + buf = fcall.data + except RpcError: + self.close() + raise + + self.pos += len(buf) + return buf + + def write(self, buf): + try: + l = self._write(self.F, self.pos, buf).count + self.pos += l + return l + except RpcError: + self.close() + raise + + def stat(self, pstr): + if self.walk(pstr) is None: + return + try: + fc = self._stat(self.F) + finally: + self.close() + return fc.stat + + def lsdir(self): + ret = [] + while 1: + buf = self.read(self.msize) + if len(buf) == 0: + break + p9 = Marshal9P() + p9.setBuffer(buf) + p9.buf.seek(0) + fcall = Fcall(Rstat) + try: + p9.decstat(fcall.stat, 0) + except: + self.close() + print('unexpected decstat error:') + traceback.print_exc() + raise + ret += fcall.stat + return ret + + def ls(self, long=0, args=[]): + ret = [] + + if len(args) == 0: + if self.open() is None: + return + if long: + ret = [z.tolstr() for z in self.lsdir()] + else: + ret = [z.name for z in self.lsdir()] + self.close() + else: + for x in args: + stat = self.stat(x) + if not stat: + return # stat already printed a message + if len(stat) == 1: + if stat[0].mode & DMDIR: + self.open(x) + lsd = self.lsdir() + if long: + ret += [z.tolstr() for z in lsd] + else: + ret += [x + '/' + z.name for z in lsd] + self.close() + else: + if long: + # we already have full path+name, but tolstr() + # wants to append the name to the end anyway, so + # strip the last basename out to form identical + # path+name + ret.append(stat[0].tolstr( + x[0:-len(stat[0].name) - 1])) + else: + ret.append(x) + else: + print('%s: returned multiple stats (internal error)' % x) + return ret + + def cd(self, pstr): + q = self.walk(pstr) + if q is None: + return 0 + if q and not (q[-1].type & QTDIR): + print("%s: not a directory" % pstr) + self.close() + return 0 + self.F, self.CWD = self.CWD, self.F + self.close() + return 1 diff --git a/src/py9pfactotum/_vendor/py9p/utils.py b/src/py9pfactotum/_vendor/py9p/utils.py new file mode 100644 index 0000000..7217784 --- /dev/null +++ b/src/py9pfactotum/_vendor/py9p/utils.py @@ -0,0 +1,13 @@ +import sys + +if sys.version_info[0] == 2: + def bytes3(x): + if isinstance(x, unicode): + return bytes(x.encode('utf-8')) + else: + return bytes(x) +else: + def bytes3(x): + return bytes(x, 'utf-8') + + diff --git a/src/py9pfactotum/client.py b/src/py9pfactotum/client.py new file mode 100644 index 0000000..0344a51 --- /dev/null +++ b/src/py9pfactotum/client.py @@ -0,0 +1,138 @@ +import enum + +from contextlib import contextmanager +from typing import Any, Dict, Generator, Union +from ._vendor.py9p import py9p +from .errors import FactotumClientError +from .util import server_socket + + +@enum.unique +class _File(enum.Enum): + CTL = 'ctl' + RPC = 'rpc' + PROTO = 'proto' + CONFIRM = 'confirm' + NEEDKEY = 'needkey' + LOG = 'log' + + +@enum.unique +class Proto(enum.Enum): + P9ANY = 'p9any' + P9SK1 = 'p9sk1' + P9SK2 = 'p9sk2' + P9CR = 'p9cr' + APOP = 'apop' + CRAM = 'cram' + CHAP = 'chap' + DSA = 'dsa' + MSCHAP = 'mschap' + RSA = 'rsa' + PASS = 'pass' + VNC = 'vnc' + WEP = 'wep' + + +@enum.unique +class Role(enum.Enum): + CLIENT = 'client' + SERVER = 'server' + SPEAKSFOR = 'speaksfor' + ENCRYPT = 'encrypt' + DECRYPT = 'decrypt' + SIGN = 'sign' + VERIFY = 'verify' + + +class FactotumClient(py9p.Client): + """Implements a Factotum 9p client""" + + def __init__(self) -> None: + s = server_socket() + c = py9p.Credentials('') + super().__init__(s, c) + + @contextmanager + def _connection(self, *args: Any, + **kwargs: Any) -> Generator[object, None, None]: + try: + self.open(*args, **kwargs) + yield self + finally: + self.close() + + def _ctl(self, msg: Union[str, bytes]) -> None: + if isinstance(msg, str): + msg = bytes(msg, 'utf-8') + with self._connection(_File.CTL.value): + try: + self.write(msg) + except py9p.RpcError as r: + raise FactotumClientError('_ctl', r) + + @staticmethod + def _kwargs_to_key_tuple(**kwargs: str) -> str: + """Converts a dictionary into a factotum key tuple.""" + s = '' + for k, v in kwargs.items(): + if not isinstance(v, str): + raise FactotumClientError(f'value of {k} is not a string', v) + s = f'{s} {k}={v}' + return s + + def debug(self) -> None: + """Toggle debugging on the server""" + self._ctl('debug') + + def getpass(self, **kwargs: str) -> Dict[str, str]: + """Gets a password from the factotum server.""" + with self._connection(_File.RPC.value, py9p.ORDWR): + try: + if 'proto' in kwargs.keys() and kwargs['proto'] != 'pass': + raise FactotumClientError('getpass', 'wrong proto', + kwargs['proto']) + else: + kwargs['proto'] = 'pass' + if 'role' in kwargs.keys() and kwargs['role'] != 'client': + raise FactotumClientError('getpass', 'wrong role', + kwargs['role']) + else: + kwargs['role'] = 'client' + t = self._kwargs_to_key_tuple(**kwargs) + self.write(bytes(f'start {t}', 'utf-8')) + self.read(self.msize).decode('utf-8') + self.write(b'read') + r = self.read(self.msize).decode('utf-8').split(' ') + if len(r) == 3: + user = r[1] + password = r[2] + r = r[0] + else: + raise FactotumClientError(' '.join(r)) + if r != 'ok': + raise FactotumClientError(r) + return {'user': user, 'passwd': password} + except py9p.RpcError as r: + raise FactotumClientError('getpass', r) from None + + def key(self, public: Dict[str, str], private: Dict[str, str]) -> None: + """ + Add a key to the server via its control file. Public and + private are dictionaries containing the keys and values. + Keys in the private dictionary will be prepended with '!' + before being sent to the server. + """ + msg = 'key' + for k, v in public.items(): + msg = f'{msg} {k}={v}' + for k, v in public.items(): + msg = f'{msg} !{k}={v}' + self._ctl(msg) + + def delkey(self, **kwargs: str) -> None: + """Deletes a key from the factotum server.""" + msg = 'delkey' + for k, v in kwargs.items(): + msg = f'{msg} {k}={v}' + self._ctl(msg) diff --git a/src/py9pfactotum/errors.py b/src/py9pfactotum/errors.py new file mode 100644 index 0000000..a877d5e --- /dev/null +++ b/src/py9pfactotum/errors.py @@ -0,0 +1,10 @@ +class FactotumError(Exception): + pass + + +class FactotumClientError(FactotumError): + pass + + +class FactotumSocketError(FactotumError): + pass diff --git a/src/py9pfactotum/keyring.py b/src/py9pfactotum/keyring.py new file mode 100644 index 0000000..3653c9e --- /dev/null +++ b/src/py9pfactotum/keyring.py @@ -0,0 +1,53 @@ +""" +The keyring module implements a keyring backend. +""" +from typing import NoReturn, Optional +from keyring.backend import KeyringBackend +from keyring.credentials import SimpleCredential +from keyring.errors import PasswordDeleteError, PasswordSetError +from keyring.util import properties +from .client import FactotumClient + + +class FactotumBackend(KeyringBackend): + + @properties.ClassProperty + @classmethod + def priority(cls): + return 1 + + def get_password(self, service: str, username: str) -> str: + """Get password of the username for the service""" + c = FactotumClient() + r = c.getpass(server=service, user=username) + return r['passwd'] + + def set_password(self, service: str, username: str, + password: str) -> NoReturn: + """ + We're not able to persist passwords through the factotum + interface. The user should update the source file manually. + """ + raise PasswordSetError("cannot update factotum file") + + def delete_password(self, service: str, username: str) -> NoReturn: + """ + We're not able to delete passwords through the factotum + interface. The user should update the source file manually. + """ + raise PasswordDeleteError("cannot update factotum file") + + def get_credential(self, service: str, + username: Optional[str]) -> Optional[SimpleCredential]: + """Gets the username and password for the service. + Returns a Credential instance. + The *username* argument is optional and may be omitted by + the caller or ignored by the backend. Callers must use the + returned username. + """ + c = FactotumClient() + if username: + r = c.getpass(server=service, user=username) + else: + r = c.getpass(server=service) + return SimpleCredential(r['user'], r['passwd']) diff --git a/src/py9pfactotum/util.py b/src/py9pfactotum/util.py new file mode 100644 index 0000000..f694dad --- /dev/null +++ b/src/py9pfactotum/util.py @@ -0,0 +1,17 @@ +import os +import socket +from .errors import FactotumSocketError + + +def server_socket() -> socket.socket: + s = socket.socket(socket.AF_UNIX) + if 'NAMESPACE' in os.environ: + n = os.environ["NAMESPACE"] + s.connect(f'{n}/factotum') + elif 'USER' in os.environ and 'DISPLAY' in os.environ: + u = os.environ['USER'] + d = os.environ['DISPLAY'] + s.connect(f'/tmp/ns.{u}.{d}/factotum') + else: + raise FactotumSocketError('cannot find factotum socket path') + return s diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..57c2d62 --- /dev/null +++ b/tox.ini @@ -0,0 +1,11 @@ +[tox] +requires = flit_core >=3.2,<4 +isolated_build = true + +[flake8] +exclude = _vendor + +[testenv] +deps = -r requirements-dev.txt +commands = flake8 src/py9pfactotum + mypy src/py9pfactotum