├── .gitignore ├── LICENSE ├── README.md └── fuse_rsync.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Jonas Zaddach 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | fuse-rsync 2 | ========== 3 | 4 | A FUSE filesystem that lets you mount remote rsync modules. 5 | 6 | ## Installation ## 7 | Install the python-fuse package on your distro (at least that is how it is 8 | called in Ubuntu). 9 | 10 | ## Usage ## 11 | The module has several mount options: 12 | - __host__: The rsync host where the module is hosted. If the host is listening 13 | on a non-standard port, just append the port with the usual colon 14 | notation. 15 | - __module__: Name of the rsync module. 16 | - __path__: If you do not want to export the root of the module but a subpath, 17 | specify the subpath here. 18 | - __user__: User name that is used to connect to that module. 19 | - __password__: Rsync password that is used to connect to that module. 20 | 21 | A sample invocation of the command could look like this: 22 | 23 | *python fuse_rsync.py -o host=remoteserver,module=mymodule,user=nobody,password=none /mnt* 24 | 25 | to connect to the rsync module *mymodule* on *remoteserver* with user *nobody* and password *none* 26 | and mount it to */mnt*. Other useful switches might be *-f* to keep the execution of the program 27 | in foreground and see logging output, and *-d* to see debugging output. 28 | 29 | To unmount, use *fusermount -u /mnt*. 30 | 31 | ## Notes ## 32 | 33 | There is some rudimentary caching for filesystem attributes, which should work fine because 34 | the filesystem is read-only, but might be a nuisance if files are changed frequently on the remote 35 | side. Broken symlinks will currently kill the program, and symlinks are converted to files in the 36 | process of copying. Overall the program has a hacky quality and should be taken more for learning 37 | than for production purposes. 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /fuse_rsync.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | #Copyright (c) 2014 Jonas Zaddach 4 | #Licensed under the MIT License (https://github.com/zaddach/fuse-rsync/blob/master/LICENSE) 5 | 6 | import os 7 | import sys 8 | import errno 9 | import optparse 10 | import logging 11 | import subprocess 12 | import stat 13 | import datetime 14 | import time 15 | import re 16 | import fuse 17 | from tempfile import mkstemp 18 | from threading import Lock 19 | 20 | fuse.fuse_python_api = (0, 2) 21 | log = logging.getLogger('fuse_rsync') 22 | 23 | class RsyncModule(): 24 | """ 25 | This class implements access to an Rsync module. 26 | """ 27 | def __init__(self, host, module, user = None, password = None): 28 | self._environment = os.environ.copy() 29 | self._remote_url = "rsync://" 30 | if not user is None: 31 | self._remote_url += user + "@" 32 | 33 | self._remote_url += host + "/" + module 34 | 35 | if not password is None: 36 | self._environment['RSYNC_PASSWORD'] = password 37 | self._attr_cache = {} 38 | 39 | def _parse_attrs(self, attrs): 40 | """ 41 | Parse the textual representation of file attributes to binary representation. 42 | """ 43 | result = 0 44 | if attrs[0] == 'd': 45 | result |= stat.S_IFDIR 46 | elif attrs[0] == 'l': 47 | result |= stat.S_IFLNK 48 | elif attrs[0] == '-': 49 | result |= stat.S_IFREG 50 | else: 51 | assert(False) 52 | 53 | for i in range(0, 3): 54 | val = 0 55 | if 'r' in attrs[1 + 3 * i: 4 + 3 * i]: 56 | val |= 4 57 | if 'w' in attrs[1 + 3 * i: 4 + 3 * i]: 58 | val |= 2 59 | if 'x' in attrs[1 + 3 * i: 4 + 3 * i]: 60 | val |= 1 61 | result |= val << ((2 - i) * 3) 62 | 63 | return result 64 | 65 | def list(self, path = '/'): 66 | """ 67 | List files contained in directory __path__. 68 | Returns a list of dictionaries with keys *attrs* (numerical attribute 69 | representation), *size* (file size), *timestamp* (File's atime timestamp 70 | in a datetime object) and *filename* (The file's name). 71 | """ 72 | # See http://stackoverflow.com/questions/10323060/printing-file-permissions-like-ls-l-using-stat2-in-c for modes 73 | RE_LINE = re.compile("^([ldcbps-]([r-][w-][x-]){3})\s+([0-9]+)\s+([0-9]{4}/[0-9]{2}/[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}) (.*)$") 74 | remote_url = self._remote_url + path 75 | try: 76 | cmdline = ["rsync", "--list-only", remote_url] 77 | log.debug("executing %s", " ".join(cmdline)) 78 | output = subprocess.check_output(["rsync", "--list-only", remote_url], env = self._environment) 79 | 80 | listing = [] 81 | for line in output.decode(encoding = 'iso-8859-1').split("\n"): 82 | match = RE_LINE.match(line) 83 | 84 | if not match: 85 | continue 86 | 87 | listing.append({ 88 | "attrs": self._parse_attrs(match.group(1)), 89 | "size": int(match.group(3)), 90 | "timestamp": datetime.datetime.strptime(match.group(4), "%Y/%m/%d %H:%M:%S"), 91 | "filename": match.group(5) 92 | }) 93 | return listing 94 | except subprocess.CalledProcessError as err: 95 | if err.returncode == 23: 96 | return [] 97 | raise err 98 | 99 | def copy(self, remotepath = '/', localpath = None): 100 | """ 101 | Copy a file from the remote rsync module to the local filesystem. 102 | If no local destination is specified in __localpath__, a temporary 103 | file is created and its filename returned. The temporary file has 104 | to be deleted by the caller. 105 | """ 106 | remote_url = self._remote_url + remotepath 107 | if localpath is None: 108 | (file, localpath) = mkstemp() 109 | os.close(file) 110 | cmdline = ["rsync", "--copy-links", remote_url, localpath] 111 | log.debug("executing %s", " ".join(cmdline)) 112 | subprocess.check_call(cmdline, env = self._environment) 113 | 114 | return localpath 115 | 116 | class FuseRsyncFileInfo(fuse.FuseFileInfo): 117 | """ 118 | Encapsulates the file handle for an opened file. 119 | """ 120 | def __init__(self, handle, **kw): 121 | super(FuseRsyncFileInfo, self).__init__(**kw) 122 | self.keep = True 123 | self.handle = handle 124 | 125 | class FuseRsync(fuse.Fuse): 126 | """ 127 | The implementation of the FUSE filesystem. 128 | """ 129 | def __init__(self, *args, **kw): 130 | self.host = None 131 | self.module = None 132 | self.user = None 133 | self.password = None 134 | self.path = "/" 135 | 136 | self._attr_cache = {} 137 | self._file_cache = {} 138 | self._file_cache_lock = Lock() 139 | 140 | fuse.Fuse.__init__(self, *args, **kw) 141 | 142 | self.parser.add_option(mountopt = 'user', default = None, help = "Rsync user on the remote host") 143 | self.parser.add_option(mountopt = 'password', type = str, default = None, help = "Rsync password on the remote host") 144 | self.parser.add_option(mountopt = 'host', type = str, help = "Rsync remote host") 145 | self.parser.add_option(mountopt = 'module', type = str, help = "Rsync module on remote host") 146 | self.parser.add_option(mountopt = 'path', type = str, default = "/", help = "Rsync path in module on remote host that is supposed to be the root point") 147 | 148 | # Helpers 149 | # ======= 150 | 151 | def _full_path(self, partial): 152 | if partial.startswith("/"): 153 | partial = partial[1:] 154 | path = os.path.join(self.path, partial) 155 | return path 156 | 157 | def init(self): 158 | options = self.cmdline[0] 159 | log.debug("Invoked fsinit() with host=%s, module=%s, user=%s, password=%s", options.host, options.module, options.user, options.password) 160 | self._rsync = RsyncModule(options.host, options.module, options.user, options.password) 161 | 162 | # Filesystem methods 163 | # ================== 164 | 165 | #def access(self, path, mode): 166 | #full_path = self._full_path(path) 167 | #if not os.access(full_path, mode): 168 | #raise FuseOSError(errno.EACCES) 169 | 170 | #def chmod(self, path, mode): 171 | #full_path = self._full_path(path) 172 | #return os.chmod(full_path, mode) 173 | 174 | #def chown(self, path, uid, gid): 175 | #full_path = self._full_path(path) 176 | #return os.chown(full_path, uid, gid) 177 | 178 | def getattr(self, path, fh=None): 179 | try: 180 | log.debug("Invoked getattr('%s')", path) 181 | 182 | path = self._full_path(path) 183 | 184 | st = fuse.Stat() 185 | 186 | if path == "/": 187 | st.st_atime = int(time.time()) 188 | st.st_ctime = int(time.time()) 189 | st.st_mode = stat.S_IFDIR | 0555 190 | st.st_mtime = int(time.time()) 191 | st.st_nlink = 2 192 | st.st_uid = os.geteuid() 193 | st.st_gid = os.getegid() 194 | return st 195 | 196 | if path in self._attr_cache: 197 | info = self._attr_cache[path] 198 | else: 199 | listing = self._rsync.list(path) 200 | if len(listing) != 1: 201 | log.warn("Found none or several files for path") 202 | return -errno.ENOENT 203 | info = listing[0] 204 | self._attr_cache[path] = info 205 | 206 | timestamp = (info["timestamp"] - datetime.datetime(1970,1,1)).total_seconds() 207 | st.st_atime = timestamp 208 | st.st_ctime = timestamp 209 | st.st_uid = os.geteuid() 210 | st.st_gid = os.getegid() 211 | if info["attrs"] & stat.S_IFDIR: 212 | st.st_mode = stat.S_IFDIR | 0555 213 | else: 214 | st.st_mode = stat.S_IFREG | 0444 215 | st.st_mtime = timestamp 216 | st.st_nlink = 1 217 | st.st_size = info["size"] 218 | 219 | return st 220 | except Exception as ex: 221 | log.exception("while doing getattr") 222 | return -errno.ENOENT 223 | 224 | def readdir(self, path, offset): 225 | try: 226 | if not path.endswith("/"): 227 | path += "/" 228 | log.debug("Invoked readdir('%s')", path) 229 | 230 | full_path = self._full_path(path) 231 | 232 | yield fuse.Direntry('.') 233 | yield fuse.Direntry('..') 234 | 235 | for dirent in self._rsync.list(full_path): 236 | if dirent["filename"] == ".": 237 | continue 238 | self._attr_cache[path + dirent["filename"]] = dirent 239 | yield fuse.Direntry(str(dirent["filename"])) 240 | except Exception as ex: 241 | log.exception("While doing readdir") 242 | 243 | #def readlink(self, path): 244 | #pathname = os.readlink(self._full_path(path)) 245 | #if pathname.startswith("/"): 246 | ## Path name is absolute, sanitize it. 247 | #return os.path.relpath(pathname, self.root) 248 | #else: 249 | #return pathname 250 | 251 | #def mknod(self, path, mode, dev): 252 | #return os.mknod(self._full_path(path), mode, dev) 253 | 254 | #def rmdir(self, path): 255 | #full_path = self._full_path(path) 256 | #return os.rmdir(full_path) 257 | 258 | #def mkdir(self, path, mode): 259 | #return os.mkdir(self._full_path(path), mode) 260 | 261 | #def statfs(self, path): 262 | #full_path = self._full_path(path) 263 | #stv = os.statvfs(full_path) 264 | #return dict((key, getattr(stv, key)) for key in ('f_bavail', 'f_bfree', 265 | #'f_blocks', 'f_bsize', 'f_favail', 'f_ffree', 'f_files', 'f_flag', 266 | #'f_frsize', 'f_namemax')) 267 | 268 | #def unlink(self, path): 269 | #return os.unlink(self._full_path(path)) 270 | 271 | #def symlink(self, target, name): 272 | #return os.symlink(self._full_path(target), self._full_path(name)) 273 | 274 | #def rename(self, old, new): 275 | #return os.rename(self._full_path(old), self._full_path(new)) 276 | 277 | #def link(self, target, name): 278 | #return os.link(self._full_path(target), self._full_path(name)) 279 | 280 | #def utimens(self, path, times=None): 281 | #return os.utime(self._full_path(path), times) 282 | 283 | ## File methods 284 | ## ============ 285 | 286 | def open(self, path, flags): 287 | log.debug("invoking open(%s, %d)", path, flags) 288 | 289 | full_path = self._full_path(path) 290 | if flags & (os.O_RDONLY | os.O_WRONLY | os.O_RDWR) != os.O_RDONLY: 291 | return -errno.EACCES 292 | 293 | with self._file_cache_lock: 294 | if not path in self._file_cache: 295 | localfile = self._rsync.copy(full_path) 296 | self._file_cache[path] = {"refcount": 1, "localpath": localfile} 297 | else: 298 | self._file_cache[path]["refcount"] += 1 299 | localfile = self._file_cache[path]["localpath"] 300 | 301 | handle = os.open(localfile, os.O_RDONLY) 302 | log.debug("Created file handle %d", handle) 303 | return FuseRsyncFileInfo(handle) 304 | 305 | #def create(self, path, mode, fi=None): 306 | #full_path = self._full_path(path) 307 | #return os.open(full_path, os.O_WRONLY | os.O_CREAT, mode) 308 | 309 | def read(self, path, length, offset, fh): 310 | log.debug("invoking read(%s, %d, %d, %d)", path, length, offset, fh.handle) 311 | os.lseek(fh.handle, offset, os.SEEK_SET) 312 | return os.read(fh.handle, length) 313 | 314 | #def write(self, path, buf, offset, fh): 315 | #os.lseek(fh, offset, os.SEEK_SET) 316 | #return os.write(fh, buf) 317 | 318 | #def truncate(self, path, length, fh=None): 319 | #full_path = self._full_path(path) 320 | #with open(full_path, 'r+') as f: 321 | #f.truncate(length) 322 | 323 | #def flush(self, path, fh): 324 | #return os.fsync(fh) 325 | 326 | def release(self, path, dummy, fh): 327 | log.debug("invoking release(%s, %d, %d)", path, dummy, fh.handle) 328 | os.close(fh.handle) 329 | 330 | with self._file_cache_lock: 331 | self._file_cache[path]["refcount"] -= 1 332 | if self._file_cache[path]["refcount"] <= 0: 333 | localfile = self._file_cache[path]["localpath"] 334 | del self._file_cache[path] 335 | os.unlink(localfile) 336 | 337 | #def fsync(self, path, fdatasync, fh): 338 | #return self.flush(path, fh) 339 | 340 | if __name__ == '__main__': 341 | fs = FuseRsync() 342 | fs.parse(errex=1) 343 | #TODO: Below is hacky, find properly parsed debug attribute 344 | if '-d' in sys.argv: 345 | logging.basicConfig(level = logging.DEBUG) 346 | else: 347 | logging.basicConfig(level = logging.ERROR) 348 | fs.init() 349 | fs.main() 350 | --------------------------------------------------------------------------------