├── .gitignore ├── bashfs ├── __init__.py ├── __main__.py └── bashfs.py ├── translator.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ -------------------------------------------------------------------------------- /bashfs/__init__.py: -------------------------------------------------------------------------------- 1 | from .bashfs import BashFS 2 | -------------------------------------------------------------------------------- /translator.py: -------------------------------------------------------------------------------- 1 | import string 2 | import argparse 3 | 4 | 5 | BLACKLIST = ">? " 6 | TO_CONVERT = [chr(i) for i in range(0x40) if chr(i) not in BLACKLIST] + ["~", chr(0x7F)] 7 | 8 | def parse_args(): 9 | parser = argparse.ArgumentParser() 10 | 11 | parser.add_argument("input_file", type=argparse.FileType("r"), 12 | help="The file to translate") 13 | 14 | return parser.parse_args() 15 | 16 | def translate(decoded): 17 | encoded = "" 18 | for c in decoded: 19 | if c not in TO_CONVERT: 20 | encoded += c 21 | else: 22 | encoded += "!" + chr(ord(c) ^ 0x40) 23 | return encoded 24 | 25 | def main(): 26 | args = parse_args() 27 | print(translate(args.input_file.read())) 28 | 29 | if __name__ == "__main__": 30 | main() 31 | -------------------------------------------------------------------------------- /bashfs/__main__.py: -------------------------------------------------------------------------------- 1 | import trio 2 | import pyfuse3 3 | import logging 4 | from . import BashFS 5 | from argparse import ArgumentParser 6 | 7 | 8 | def parse_args(): 9 | parser = ArgumentParser() 10 | 11 | parser.add_argument("mountpoint", type=str, 12 | help="Where to mount the file system") 13 | parser.add_argument("--debug", action="store_true", default=False, 14 | help="Enable debugging output") 15 | parser.add_argument("--debug-fuse", action="store_true", default=False, 16 | help="Enable FUSE debugging output") 17 | 18 | parser.add_argument("--argv-prefix", action="append", 19 | help="The argv array that preceeds the filesystem path." 20 | "\nFor example, `--argv-prefix=bash --argv-prefix=-c`" 21 | "\nis equivalent to the default.") 22 | 23 | parser.add_argument("--separator", default="|", 24 | help="The string to insert between path elements.") 25 | 26 | return parser.parse_args() 27 | 28 | 29 | def main(): 30 | logging.basicConfig() 31 | args = parse_args() 32 | if args.debug: 33 | logging.getLogger().setLevel(logging.DEBUG) 34 | 35 | argv = args.argv_prefix if args.argv_prefix else ("bash", "-c") 36 | operations = BashFS(argv_prefix=argv, separator=args.separator.encode()) 37 | 38 | fuse_options = set(pyfuse3.default_options) 39 | fuse_options.add("fsname=bashfs") 40 | fuse_options.discard("default_permissions") 41 | if args.debug_fuse: 42 | fuse_options.add("debug") 43 | pyfuse3.init(operations, args.mountpoint, fuse_options) 44 | 45 | try: 46 | trio.run(pyfuse3.main) 47 | except: 48 | pyfuse3.close(unmount=True) 49 | raise 50 | 51 | pyfuse3.close() 52 | 53 | 54 | main() 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bashfs 2 | 3 | ## Overview 4 | 5 | `bashfs` is a pyfuse3-based filesystem that allows you to interact with your favorite command line applications from the filesystem. For example: 6 | 7 | ``` shell 8 | / $ mkdir mnt 9 | / $ python -m bashfs mnt & 10 | / $ cd mnt 11 | /mnt $ cat 'ls -a/run' 12 | . 13 | .. 14 | bashfs 15 | .git 16 | .gitignore 17 | mnt 18 | README.md 19 | translator.py 20 | /mnt $ cd 'ls -a' 21 | /mnt/ls -a $ cd 'grep t' 22 | /mnt/ls -a/grep t $ cat run 23 | .git 24 | .gitignore 25 | mnt 26 | translator.py 27 | ``` 28 | 29 | ## Running other programs 30 | 31 | By default, paths are passed to `bash -c` with slashes replaced with pipes. You can change this behavior via command-line options to bashfs: 32 | 33 | ``` shell 34 | / $ mkdir mnt 35 | / $ python -m bashfs mnt --argv-prefix=python --argv-prefix=-c --separator=' 36 | ' & 37 | / $ cd mnt 38 | /mnt $ cd 'a = "hello"' 39 | /mnt/a = "hello" $ cd 'print(a)' 40 | /mnt/a = "hello"/print(a) $ cat run 41 | hello 42 | ``` 43 | 44 | ## Escaping characters 45 | 46 | Certain characters are not allowed in paths; for example, a slash cannot be entered without being interpreted as a path separator. `bashfs` allows you to escape these characters by inserting an exclamation point (`!`) before the desired character XORed with 0x40. For example, to enter a slash, `!o` would be used, because `'o' = 0x6F = 0x2F ^ 0x40 = '/' ^ 0x40`: 47 | 48 | ``` shell 49 | / $ mkdir mnt 50 | / $ python -m bashfs mnt & 51 | / $ cd mnt 52 | /mnt $ cat '!obin!ols -a/run' 53 | . 54 | .. 55 | bashfs 56 | .git 57 | .gitignore 58 | mnt 59 | README.md 60 | translator.py 61 | ``` 62 | 63 | ## Installation 64 | 65 | `bashfs` requires `pyfuse3`; however, it needs a particular feature which is currently pending upstreaming. See [this pull request](https://github.com/libfuse/pyfuse3/pull/17) for more information. 66 | 67 | After a compatible version of `pyfuse3` is installed, no other installation should be necessary; `bashfs` should be runnable from wherever it is cloned. 68 | 69 | ## "Why?" 70 | 71 | Why not? 72 | -------------------------------------------------------------------------------- /bashfs/bashfs.py: -------------------------------------------------------------------------------- 1 | import trio 2 | import errno 3 | import signal 4 | import pyfuse3 5 | import logging 6 | import subprocess 7 | from itertools import count 8 | 9 | 10 | l = logging.getLogger("bashfs.bashfs") 11 | 12 | BASHFS_ESCAPE = b"!" 13 | BASHFS_RUN = b"run" 14 | 15 | class Node: 16 | def __init__(self, parent, name, num, is_root=False): 17 | self.is_root = is_root 18 | self.parent = parent 19 | self.num = num 20 | if not self.is_root: 21 | if len(name) <= 0: 22 | raise ValueError("name must be non-empty") 23 | if b"/" in name: 24 | raise ValueError("name cannot contain banned characters") 25 | else: 26 | self.parent = self 27 | self.name = name 28 | self.translated = self.translate(name) 29 | self.is_last = name == BASHFS_RUN 30 | self.children = {} 31 | 32 | @staticmethod 33 | def translate(encoded): 34 | if encoded is None: 35 | return None 36 | out = "" 37 | iterator = iter(encoded) 38 | for c in iterator: 39 | if c == ord(b"!"): 40 | try: 41 | n = next(iterator) 42 | except StopIteration: 43 | raise pyfuse3.FUSEError(errno.ENOENT) 44 | out += chr(n ^ 0x40) 45 | else: 46 | out += chr(c) 47 | return out.encode("ascii") 48 | 49 | def make_path(self, sep): 50 | if not self.is_root: 51 | p = self.parent.make_path(sep) 52 | if self.is_last: 53 | return p if p else b"" 54 | if p: 55 | return p + sep + self.translated 56 | else: 57 | return self.translated 58 | return None 59 | 60 | def __repr__(self): 61 | return "" % (self.num, self.name, self.make_path()) 62 | 63 | def add_child(self, child): 64 | self.children[child.name] = child 65 | 66 | class BashFS(pyfuse3.Operations): 67 | enable_writeback_cache = False 68 | 69 | def __init__(self, argv_prefix=("bash", "-c"), separator=b"|"): 70 | super(pyfuse3.Operations, self).__init__() 71 | signal.signal(signal.SIGCHLD, signal.SIG_IGN) 72 | self.argv_prefix = list(argv_prefix) 73 | self.separator = separator 74 | self._inode_generator = count(start=10) 75 | self._file_generator = count(start=10) 76 | self._inode_map = {pyfuse3.ROOT_INODE: Node(None, None, 77 | pyfuse3.ROOT_INODE, 78 | is_root=True)} 79 | self._proc_map = {} 80 | 81 | def _make_child_node(self, node_p, name): 82 | new_num = next(self._inode_generator) 83 | new_node = Node(node_p, name, new_num) 84 | node_p.add_child(new_node) 85 | self._inode_map[new_num] = new_node 86 | return new_node 87 | 88 | def _get_or_create_child_node(self, node_p, name): 89 | if name in node_p.children: 90 | return node_p.children[name] 91 | return self._make_child_node(node_p, name) 92 | 93 | def _get_node(self, inode): 94 | return self._inode_map[inode] 95 | 96 | async def lookup(self, inode_p, name, ctx=None): 97 | l.debug("lookup: inode %r, name %r", inode_p, name) 98 | if name == b".": 99 | return inode_p 100 | if name == b"..": 101 | return inode_p.parent 102 | node_p = self._get_node(inode_p) 103 | res_node = self._get_or_create_child_node(node_p, name) 104 | return await self.getattr(res_node.num, ctx=ctx) 105 | 106 | async def getattr(self, inode, ctx=None): 107 | node = self._get_node(inode) 108 | 109 | l.debug("getattr: %r", node) 110 | 111 | entry = pyfuse3.EntryAttributes() 112 | entry.st_ino = inode 113 | entry.generation = 0 114 | entry.entry_timeout = 0 115 | entry.attr_timeout = 0 116 | if not node.is_last: 117 | entry.st_mode = 0o040777 118 | else: 119 | entry.st_mode = 0o100777 120 | entry.st_nlink = 1 121 | 122 | entry.st_uid = 1000 123 | entry.st_gid = 1000 124 | entry.st_rdev = 0 125 | if not node.is_last: 126 | entry.st_size = 4096 127 | else: 128 | entry.st_size = 0 129 | 130 | entry.st_blksize = 512 131 | entry.st_blocks = 1 132 | entry.st_atime_ns = 1 133 | entry.st_mtime_ns = 1 134 | entry.st_ctime_ns = 1 135 | return entry 136 | 137 | async def access(self, inode, mode, ctx): 138 | return True 139 | 140 | async def statfs(self, ctx): 141 | stat_ = pyfuse3.StatvfsData() 142 | 143 | stat_.f_bsize = 512 144 | stat_.f_frsize = 512 145 | 146 | stat_.f_blocks = 0 147 | stat_.f_bfree = 20 148 | stat_.f_bavail = 20 149 | 150 | thing = len(self._inode_map) 151 | stat_.f_files = thing 152 | stat_.f_ffree = thing + 2 153 | stat_.f_favail = thing + 2 154 | 155 | return stat_ 156 | 157 | async def opendir(self, inode, ctx): 158 | return inode 159 | 160 | async def readdir(self, inode, off, token): 161 | l.debug("readdir: inode=%r, off=%r, token=%r", inode, off, token) 162 | if off < 1: 163 | pyfuse3.readdir_reply(token, BASHFS_RUN, 164 | await self.lookup(inode, BASHFS_RUN), 1) 165 | return None 166 | 167 | async def open(self, inode, flags, ctx): 168 | path = self._get_node(inode).make_path(self.separator) 169 | l.debug("open: %r", path) 170 | file_handle = next(self._file_generator) 171 | proc = await trio.open_process(self.argv_prefix + [path.decode()], 172 | stdout=subprocess.PIPE, 173 | stdin=subprocess.PIPE) 174 | self._proc_map[file_handle] = proc 175 | return pyfuse3.FileInfo(fh=file_handle, direct_io=True, 176 | keep_cache=False, nonseekable=True) 177 | 178 | async def read(self, file_handle, offset, length): 179 | p = self._proc_map[file_handle] 180 | l.debug("read: file_handle=%r, offset=%r, length=%r", file_handle, offset, length) 181 | res = await p.stdout.receive_some(length) 182 | l.debug("\t-> %r", res) 183 | return res 184 | 185 | async def write(self, file_handle, offset, data): 186 | p = self._proc_map[file_handle] 187 | return await p.stdin.send_all(data) 188 | 189 | async def release(self, file_handle): 190 | if self._proc_map[file_handle].poll() is None: 191 | self._proc_map[file_handle].terminate() 192 | 193 | async def flush(self, file_handle): 194 | return True 195 | 196 | async def releasedir(self, inode): 197 | return None 198 | --------------------------------------------------------------------------------