e114570092ac1722ce443494db90861c33216ddb
[rsync.git] / support / rrsync
1 #!/usr/bin/env python3
2
3 # Restricts rsync to subdirectory declared in .ssh/authorized_keys.  See
4 # the rrsync man page for details of how to make use of this script.
5
6 # NOTE: install python3 braceexpand to support brace expansion in the args!
7
8 # Originally a perl script by: Joe Smith <js-cgi@inwap.com> 30-Sep-2004
9 # Python version by: Wayne Davison <wayne@opencoder.net>
10
11 # You may configure these 2 values to your liking.  See also the section of
12 # short & long options if you want to disable any options that rsync accepts.
13 RSYNC = '/usr/bin/rsync'
14 LOGFILE = 'rrsync.log' # NOTE: the file must exist for a line to be appended!
15
16 # The following options are mainly the options that a client rsync can send
17 # to the server, and usually just in the one option format that the stock
18 # rsync produces. However, there are some additional convenience options
19 # added as well, and thus a few options are present in both the short and
20 # long lists (such as --group, --owner, and --perms).
21
22 # NOTE when disabling: check for both a short & long version of the option!
23
24 ### START of options data produced by the cull-options script. ###
25
26 # To disable a short-named option, add its letter to this string:
27 short_disabled = 's'
28
29 # These are also disabled when the restricted dir is not "/":
30 short_disabled_subdir = 'KLk'
31
32 # These are all possible short options that we will accept (when not disabled above):
33 short_no_arg = 'ACDEHIJKLNORSUWXbcdgklmnopqrstuvxyz' # DO NOT REMOVE ANY
34 short_with_num = '@B' # DO NOT REMOVE ANY
35
36 # To disable a long-named option, change its value to a -1.  The values mean:
37 # 0 = the option has no arg; 1 = the arg doesn't need any checking; 2 = only
38 # check the arg when receiving; and 3 = always check the arg.
39 long_opts = {
40   'append': 0,
41   'backup-dir': 2,
42   'block-size': 1,
43   'bwlimit': 1,
44   'checksum-choice': 1,
45   'checksum-seed': 1,
46   'compare-dest': 2,
47   'compress-choice': 1,
48   'compress-level': 1,
49   'compress-threads': 1,
50   'copy-dest': 2,
51   'copy-devices': -1,
52   'copy-unsafe-links': 0,
53   'daemon': -1,
54   'debug': -1,
55   'delay-updates': 0,
56   'delete': 0,
57   'delete-after': 0,
58   'delete-before': 0,
59   'delete-delay': 0,
60   'delete-during': 0,
61   'delete-excluded': 0,
62   'delete-missing-args': 0,
63   'dirs': 0,
64   'existing': 0,
65   'fake-super': 0,
66   'files-from': 3,
67   'force': 0,
68   'from0': 0,
69   'fsync': 0,
70   'fuzzy': 0,
71   'group': 0,
72   'groupmap': 1,
73   'hard-links': 0,
74   'iconv': 1,
75   'ignore-errors': 0,
76   'ignore-existing': 0,
77   'ignore-missing-args': 0,
78   'ignore-times': 0,
79   'info': 1,
80   'inplace': 0,
81   'link-dest': 2,
82   'links': 0,
83   'list-only': 0,
84   'log-file': 3,
85   'log-format': 1,
86   'max-alloc': 1,
87   'max-delete': 1,
88   'max-size': 1,
89   'min-size': 1,
90   'mkpath': 0,
91   'modify-window': 1,
92   'msgs2stderr': 0,
93   'munge-links': 0,
94   'new-compress': 0,
95   'no-W': 0,
96   'no-implied-dirs': 0,
97   'no-msgs2stderr': 0,
98   'no-munge-links': -1,
99   'no-r': 0,
100   'no-relative': 0,
101   'no-specials': 0,
102   'numeric-ids': 0,
103   'old-compress': 0,
104   'one-file-system': 0,
105   'only-write-batch': 1,
106   'open-noatime': 0,
107   'owner': 0,
108   'partial': 0,
109   'partial-dir': 2,
110   'perms': 0,
111   'preallocate': 0,
112   'recursive': 0,
113   'remove-sent-files': 0,
114   'remove-source-files': 0,
115   'safe-links': 0,
116   'sender': 0,
117   'server': 0,
118   'size-only': 0,
119   'skip-compress': 1,
120   'specials': 0,
121   'stats': 0,
122   'stderr': 1,
123   'suffix': 1,
124   'super': 0,
125   'temp-dir': 2,
126   'timeout': 1,
127   'times': 0,
128   'use-qsort': 0,
129   'usermap': 1,
130   'write-devices': -1,
131 }
132
133 ### END of options data produced by the cull-options script. ###
134
135 import os, sys, re, argparse, glob, socket, stat, time, subprocess
136 from argparse import RawTextHelpFormatter
137
138 # Held open across exec so rsync inherits them. Each entry pins a path
139 # validated_arg() approved; the corresponding arg passed to rsync is
140 # rewritten to /proc/self/fd/N so rsync's path resolution cannot be
141 # race-flipped after rrsync's realpath check, closing the realpath-vs-exec
142 # TOCTOU.
143 pinned_fds = []
144
145 # Directory pins, keyed by (st_dev, st_ino), so a glob or a multi-arg command
146 # whose args share a parent inherits one fd rather than one per arg.
147 pinned_dirs = {}
148
149 # Whether the client asked for --relative/-R, which decides how much of a
150 # sender arg rsync transmits as the file's name (see sender_pinned_arg).
151 client_relative = False
152
153 # The inode-pin trick needs /proc/self/fd/N to be a Linux-style magic symlink
154 # whose readlink yields the open file's real path. macOS/BSD lack the directory
155 # entirely; Solaris HAS /proc/self/fd but its entries are not such symlinks (its
156 # readlink does not return the path), so an isdir() check is not enough -- probe
157 # the actual behaviour once against a known fd. Where it works we pin (and a
158 # later readlink failure is an anomaly that fails closed); where it does not we
159 # fall through to the unhardened path.
160 #
161 # A correct readlink is NOT sufficient evidence that the fd pins anything, and
162 # the two platforms that get this wrong fail in opposite directions:
163 #
164 #   * NetBSD makes the entry a symlink for DIRECTORIES only -- readlink of a
165 #     regular file's entry fails with EINVAL, so a directory-only probe claims
166 #     support that is not there and every pull of a file dies in the post-pin
167 #     check.
168 #   * Cygwin's readlink returns the right path, but opening the magic link
169 #     RE-RESOLVES it: rename the directory out from under a held fd and the
170 #     magic link reaches the replacement. The pin silently protects nothing.
171 #
172 # Only the Linux kernel gives the inode-bound magic link this relies on, so
173 # require that explicitly and keep the runtime probes as a guard for Linux-like
174 # environments where /proc is absent or restricted (containers, seccomp).
175 def _probe_proc_self_fd():
176     if not sys.platform.startswith(('linux', 'android')):
177         return False
178
179     def resolves(path):
180         try:
181             fd = os.open(path, os.O_RDONLY)
182         except OSError:
183             return False
184         try:
185             return os.readlink('/proc/self/fd/%d' % fd) == os.path.realpath(path)
186         except OSError:
187             return False
188         finally:
189             os.close(fd)
190
191     return resolves('/') and resolves(os.path.realpath(__file__))
192
193 HAVE_PROC_SELF_FD = _probe_proc_self_fd()
194
195 try:
196     from braceexpand import braceexpand
197 except:
198     braceexpand = lambda x: [ DE_BACKSLASH_RE.sub(r'\1', x) ]
199
200 HAS_DOT_DOT_RE = re.compile(r'(^|/)\.\.(/|$)')
201 LONG_OPT_RE = re.compile(r'^--([^=]+)(?:=(.*))?$')
202 DE_BACKSLASH_RE = re.compile(r'\\(.)')
203
204 def make_inheritable(fd):
205     """Clear FD_CLOEXEC so the exec'd rsync inherits `fd`.
206
207     os.set_inheritable() prefers ioctl(FIONCLEX), which an O_PATH descriptor
208     rejects with EBADF on older kernels; F_SETFD is one of the few operations
209     O_PATH always allows.
210     """
211     try:
212         os.set_inheritable(fd, True)
213     except OSError:
214         import fcntl
215         fcntl.fcntl(fd, fcntl.F_SETFD,
216                     fcntl.fcntl(fd, fcntl.F_GETFD) & ~fcntl.FD_CLOEXEC)
217
218 def pin_dir(path, orig_arg):
219     """Inode-pin a directory and return an fd rsync will inherit.
220
221     The open resolves `path` normally -- including a symlink at its last
222     component, which is legitimate and which 3.4.4 accepts -- so a component
223     could be flipped first.  The readlink check afterwards is what makes that
224     safe: it proves the inode we ended up holding is inside the restricted
225     tree.  From then on the fd names that inode, not the path, so nothing above
226     it can be flipped again.
227
228     O_PATH, not O_RDONLY: reaching a known name beneath a directory needs only
229     search permission, and a mode 0111 parent is a perfectly ordinary way to
230     publish a file without letting it be listed.  An O_PATH directory fd is
231     just as firmly pinned when used as a /proc/self/fd/N/... prefix (the O_PATH
232     caveat in validated_arg() is about reopening the magic link as the file
233     itself, which is not what happens here).
234     """
235     flags = os.O_DIRECTORY | getattr(os, 'O_PATH', 0)
236     if not flags & getattr(os, 'O_PATH', 0):
237         flags |= os.O_RDONLY
238     try:
239         fd = os.open(path or '.', flags)
240     except OSError as e:
241         die('unable to pin sender path:', orig_arg, e.strerror)
242     try:
243         st = os.fstat(fd)
244         pinned_path = os.readlink('/proc/self/fd/%d' % fd)
245     except OSError as e:
246         os.close(fd)
247         die('post-pin readlink failed (race?):', orig_arg, e.strerror)
248     if pinned_path != args.dir and not pinned_path.startswith(args.dir_slash):
249         os.close(fd)
250         die('post-pin path escaped tree (race?):', orig_arg, pinned_path)
251     key = (st.st_dev, st.st_ino)
252     if key in pinned_dirs:
253         os.close(fd)
254         return pinned_dirs[key]
255     make_inheritable(fd)
256     pinned_fds.append(fd)
257     pinned_dirs[key] = fd
258     return fd
259
260 # Checked receiver-side directory options, split by what rsync does with a
261 # missing one.  It creates these itself on demand, so rrsync creates and pins
262 # them (0700 for the partial dir, which is what rsync uses -- partial files are
263 # incomplete copies of the peer's data and rsync deliberately does not publish
264 # them to the rest of the machine).
265 CREATE_DIR_MODE = {'--backup-dir': 0o777, '--partial-dir': 0o700}
266 # It requires this one to exist already, so a missing one stays an error.
267 MUST_EXIST_DIR_OPTS = ('--temp-dir',)
268 # And it only READS through these: a missing one is the ordinary first-run case
269 # and must keep working, so stand in an empty directory rather than refusing.
270 EMPTY_BASIS_OPTS = ('--link-dest', '--compare-dest', '--copy-dest')
271
272 def pinned_empty_dir(orig_arg):
273     """Pin an empty unlinked directory to stand in for a missing basis dir.
274
275     A basis lookup cannot tell an empty directory from a missing one, so this
276     preserves "first run has no basis" exactly -- but without leaving a name
277     the peer can win: the directory is created inside the restricted dir,
278     opened, then unlinked while we keep the fd, so what rsync is handed has no
279     path at all for an in-band symlink to take over.
280     """
281     name = '.rrsync-empty-basis.%d' % os.getpid()
282     rootfd = os.open('.', os.O_RDONLY | os.O_DIRECTORY)
283     try:
284         os.mkdir(name, 0o700, dir_fd=rootfd)
285     except FileExistsError:
286         pass   # our own leftover, or something planted; the open decides
287     except OSError as e:
288         os.close(rootfd)
289         die('unable to create basis placeholder:', orig_arg, e.strerror)
290     try:
291         fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY,
292                      dir_fd=rootfd)
293     except OSError as e:
294         os.close(rootfd)
295         die('unable to pin basis placeholder:', orig_arg, e.strerror)
296     try:
297         os.rmdir(name, dir_fd=rootfd)
298     except FileNotFoundError:
299         pass   # already gone; we hold the inode either way
300     except OSError as e:
301         # The whole point is that rsync gets an inode with no name.  If the
302         # name survives, the peer can still reach and fill the directory, so
303         # this is not a placeholder we can safely hand over.
304         os.close(rootfd)
305         os.close(fd)
306         die('unable to detach basis placeholder:', orig_arg, e.strerror)
307     os.close(rootfd)
308     if os.listdir(fd):
309         os.close(fd)
310         die('basis placeholder is not empty:', orig_arg)
311     make_inheritable(fd)
312     pinned_fds.append(fd)
313     return '/proc/self/fd/%d' % fd
314
315 def create_pinned_dir(path, orig_arg, mode):
316     """Create a missing receiver-option directory and return a pin of it.
317
318     rsync makes --backup-dir/--partial-dir itself and then works inside it, so
319     the parent-pinned /proc/self/fd/<parent>/<leaf> spelling is not enough: the
320     same transfer can plant a symlink at <leaf> first and rsync would create
321     through it, outside the restricted dir.  Creating it here, beneath the
322     already-pinned parent, means the name is a real directory before rsync ever
323     looks at it, and the O_NOFOLLOW reopen proves we hold what we made rather
324     than something that raced in between.
325     """
326     # Walk down from the restricted dir a component at a time, creating what
327     # is missing.  rsync builds a whole missing --backup-dir hierarchy itself
328     # (backup.c make_bak_dir()), so stopping at "the immediate parent must
329     # exist" would refuse a first-use dated/nested name that works today.
330     #
331     # O_NOFOLLOW on every component costs nothing and rules out a symlink
332     # anywhere along the way: `path` is a realpath, so none of its components
333     # is legitimately a symlink, and each open is relative to the fd we are
334     # already holding rather than to a name that could be reshaped underneath.
335     fd = os.open('.', os.O_RDONLY | os.O_DIRECTORY)   # the chdir'd restricted dir
336     for comp in os.path.relpath(path, args.dir).split(os.sep):
337         if comp in ('', '.', '..'):
338             os.close(fd)
339             die('bad receiver option path:', orig_arg)
340         try:
341             nfd = os.open(comp, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY,
342                           dir_fd=fd)
343         except FileNotFoundError:
344             try:
345                 os.mkdir(comp, mode, dir_fd=fd)
346             except FileExistsError:
347                 pass   # raced in; the open below decides if it is usable
348             except OSError as e:
349                 os.close(fd)
350                 die('unable to create receiver option dir:',
351                     orig_arg, e.strerror)
352             try:
353                 nfd = os.open(comp,
354                               os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY,
355                               dir_fd=fd)
356             except OSError as e:
357                 os.close(fd)
358                 die('receiver option path is not a usable directory:',
359                     orig_arg, e.strerror)
360         except OSError as e:
361             os.close(fd)
362             die('receiver option path is not a usable directory:',
363                 orig_arg, e.strerror)
364         os.close(fd)
365         fd = nfd
366     try:
367         dpath = os.readlink('/proc/self/fd/%d' % fd)
368     except OSError as e:
369         os.close(fd)
370         die('post-pin readlink failed (race?):', orig_arg, e.strerror)
371     if dpath != args.dir and not dpath.startswith(args.dir_slash):
372         os.close(fd)
373         die('post-pin path escaped tree (race?):', orig_arg, dpath)
374     make_inheritable(fd)
375     pinned_fds.append(fd)
376     return '/proc/self/fd/%d' % fd
377
378 # sender_pinned_arg() verdicts that are not a rewritten argument.
379 KEEP_LEAF_PIN = 'keep'          # hand rsync the leaf's own /proc/self/fd/N
380 LEAF_PIN_UNUSABLE = 'unusable'  # no pin for this shape; keep the plain name
381
382 def sender_pinned_arg(fd, arg, orig_arg, has_slash, has_slash_dot):
383     """Return a source name rsync will both resolve safely and name correctly.
384
385     The obvious rewrite -- hand rsync /proc/self/fd/N for the leaf itself --
386     only works where rsync open()s the argument.  A sender lstat()s it first,
387     and lstat of a procfs magic link is always S_IFLNK, so rsync describes the
388     argument as a symlink instead of sending the file.  Which pin is usable
389     therefore depends on what rsync does with the argument:
390
391       * a trailing "/" or "/." directory is opened, not lstat()ed, and rsync
392         DOES follow a symlink there, so it keeps the leaf pin;
393       * anything else keeps the pin one level up and passes the leaf by name.
394         rsync will not follow a symlink at that position (it sends the symlink
395         itself), the follow options that would change that are already disabled
396         for a restricted dir, and its own leaf open is O_NOFOLLOW.
397
398     Under --relative the transmitted name is the whole argument rather than
399     its basename, so the pin has to move up to wherever that name starts and
400     the rest is spelled after a /./ marker, which is how rsync is told where
401     the transmitted portion begins.
402     """
403     if client_relative:
404         # Look for the marker in the argument as the client spelled it: the
405         # caller has already split off a trailing "/" or "/.", which is exactly
406         # what turns "sub/./" into a terminal marker.
407         full = arg + ('/' if has_slash else '/.' if has_slash_dot else '')
408         # rsync honours the FIRST /./, so split there and keep the client's
409         # marker rather than inserting a second, earlier one.
410         head, marker, tail = full.partition('/./')
411         if marker:
412             anchor, suffix = head, tail.rstrip('/')
413         else:
414             anchor, suffix = '', arg
415         if suffix.startswith('/'):
416             return LEAF_PIN_UNUSABLE
417         if suffix in ('', '.'):
418             # A terminal marker: everything the client wants transmitted starts
419             # at the argument itself, which is the directory we already pinned.
420             # The caller re-appends the trailing "/" or "/.".
421             dfd = pin_dir(anchor, orig_arg)
422             check = '.'
423             pinned = '/proc/self/fd/%d/.' % dfd
424         else:
425             dfd = pin_dir(anchor, orig_arg)
426             pinned = '/proc/self/fd/%d/./%s' % (dfd, suffix)
427             check = suffix
428     else:
429         if has_slash or has_slash_dot:
430             return KEEP_LEAF_PIN
431         anchor, _, leaf = arg.rpartition('/')
432         if not leaf or leaf in ('.', '..'):
433             return LEAF_PIN_UNUSABLE
434         dfd = pin_dir(anchor, orig_arg)
435         pinned = '/proc/self/fd/%d/%s' % (dfd, leaf)
436         check = leaf
437
438     # Tie the pinned directory to the inode realpath() validated: resolving
439     # `check` beneath the held fd cannot be redirected above the leaf, so if it
440     # does not reach the same file, something was flipped -- fail closed.
441     # fd is None for a leaf we deliberately never opened (a symlink, or a
442     # device/FIFO/socket): there is no inode to compare against, and the leaf
443     # was never going to be content-opened by the sender either.
444     if fd is None:
445         return pinned
446     try:
447         st = os.stat(check, dir_fd=dfd)
448     except OSError as e:
449         die('post-pin stat failed (race?):', orig_arg, e.strerror)
450     leaf_st = os.fstat(fd)
451     if (st.st_dev, st.st_ino) != (leaf_st.st_dev, leaf_st.st_ino):
452         die('post-pin path changed (race?):', orig_arg, check)
453     return pinned
454
455 def safe_open_logfile():
456     nofollow = getattr(os, 'O_NOFOLLOW', 0)
457     try:
458         st = os.lstat(LOGFILE)
459     except OSError:
460         return None
461     if not stat.S_ISREG(st.st_mode):
462         return None
463     try:
464         fd = os.open(LOGFILE, os.O_WRONLY | os.O_APPEND | nofollow)
465     except OSError:
466         return None
467     st2 = os.fstat(fd)
468     if not stat.S_ISREG(st2.st_mode) or st.st_dev != st2.st_dev or st.st_ino != st2.st_ino:
469         os.close(fd)
470         return None
471     return os.fdopen(fd, 'a')
472
473 def main():
474     if not os.path.isdir(args.dir):
475         die("Restricted directory does not exist!")
476
477     # The format of the environment variables set by sshd:
478     #   SSH_ORIGINAL_COMMAND:
479     #     rsync --server          -vlogDtpre.iLsfxCIvu --etc . ARG  # push
480     #     rsync --server --sender -vlogDtpre.iLsfxCIvu --etc . ARGS # pull
481     #   SSH_CONNECTION (client_ip client_port server_ip server_port):
482     #     192.168.1.100 64106 192.168.1.2 22
483
484     command = os.environ.get('SSH_ORIGINAL_COMMAND', None)
485     if not command:
486         die("Not invoked via sshd")
487     if command == 'true':
488         # Allow checking connectivity with "ssh <host> true".  (For example,
489         # rsbackup uses this.)
490         sys.exit(0)
491     command = command.split(' ', 2)
492     if command[0:1] != ['rsync']:
493         die("SSH_ORIGINAL_COMMAND does not run rsync")
494     if command[1:2] != ['--server']:
495         die("--server option is not the first arg")
496     command = '' if len(command) < 3 else command[2]
497
498     global am_sender
499     am_sender = command.startswith("--sender ") # Restrictive on purpose!
500     if args.ro and not am_sender:
501         die("sending to read-only server is not allowed")
502     if args.wo and am_sender:
503         die("reading from write-only server is not allowed")
504
505     if args.wo or not am_sender:
506         long_opts['sender'] = -1
507     if args.no_del:
508         for opt in long_opts:
509             if opt.startswith(('remove', 'delete')):
510                 long_opts[opt] = -1
511     if args.ro:
512         long_opts['log-file'] = -1
513
514     global short_disabled
515     if args.no_overwrite:
516         # --ignore-existing guards only the live transfer destination.  These
517         # options append to, consume, move or remove other existing objects in
518         # the restricted dir.  Backup mode belongs here too: publishing a
519         # backup onto a name that already exists deletes what is there
520         # (backup.c make_backup()), and deletion backs files up as well
521         # (delete.c), so an unrelated --delete can land on a protected name.
522         long_opts['log-file'] = long_opts['partial-dir'] = long_opts['delay-updates'] = -1
523         long_opts['backup-dir'] = -1
524         short_disabled += 'b'   # must precede the short_no_arg_re build below
525
526     if args.dir != '/':
527         short_disabled += short_disabled_subdir
528         long_opts['copy-unsafe-links'] = -1
529
530     short_no_arg_re = short_no_arg
531     short_with_num_re = short_with_num
532     if short_disabled:
533         for ltr in short_disabled:
534             short_no_arg_re = short_no_arg_re.replace(ltr, '')
535             short_with_num_re = short_with_num_re.replace(ltr, '')
536         short_disabled_re = re.compile(r'^-[%s]*([%s])' % (short_no_arg_re, short_disabled))
537     short_no_arg_re = re.compile(r'^-(?=.)[%s]*(e\d*\.\w*)?$' % short_no_arg_re)
538     short_with_num_re = re.compile(r'^-[%s]\d+$' % short_with_num_re)
539
540     log_fh = safe_open_logfile()
541
542     try:
543         os.chdir(args.dir)
544     except OSError as e:
545         die('unable to chdir to restricted dir:', str(e))
546
547     global client_relative
548     rsync_opts = [ '--server' ]
549     rsync_args = [ ]
550     saw_the_dot_arg = False
551     last_opt = check_type = None
552
553     for arg in re.findall(r'(?:[^\s\\]+|\\.[^\s\\]*)+', command):
554         if check_type:
555             rsync_opts.append(validated_arg(last_opt, arg, check_type))
556             check_type = None
557         elif saw_the_dot_arg:
558             # NOTE: an arg that starts with a '-' is safe due to our use of "--" in the cmd tuple.
559             try:
560                 b_e = braceexpand(arg) # Also removes backslashes
561             except: # Handle errors such as unbalanced braces by just de-backslashing the arg:
562                 b_e = [ DE_BACKSLASH_RE.sub(r'\1', arg) ]
563             for xarg in b_e:
564                 rsync_args += validated_arg('arg', xarg, wild=True)
565         else: # parsing the option args
566             if arg == '.':
567                 saw_the_dot_arg = True
568                 continue
569             rsync_opts.append(arg)
570             sm = short_no_arg_re.match(arg)
571             if sm or short_with_num_re.match(arg):
572                 if sm:
573                     # Scan the cluster's own letters only: the trailing
574                     # capability blob (-e.iLsfxC) is not a set of options.
575                     letters = arg[1:]
576                     if sm.group(1):
577                         letters = letters[:-len(sm.group(1))]
578                     if 'R' in letters:
579                         client_relative = True
580                 continue
581             disabled = False
582             m = LONG_OPT_RE.match(arg)
583             if m:
584                 opt = m.group(1)
585                 opt_arg = m.group(2)
586                 ct = long_opts.get(opt, None)
587                 if ct is None:
588                     break # Generate generic failure due to unfinished arg parsing
589                 # Last one wins, matching rsync's own option handling.
590                 if opt == 'relative':
591                     client_relative = True
592                 elif opt == 'no-relative':
593                     client_relative = False
594                 if ct == 0:
595                     continue
596                 opt = '--' + opt
597                 if ct > 0:
598                     if opt_arg is not None:
599                         rsync_opts[-1] = opt + '=' + validated_arg(opt, opt_arg, ct)
600                     else:
601                         check_type = ct
602                         last_opt = opt
603                     continue
604                 disabled = True
605             elif short_disabled:
606                 m = short_disabled_re.match(arg)
607                 if m:
608                     disabled = True
609                     opt = '-' + m.group(1)
610
611             if disabled:
612                 die("option", opt, "has been disabled on this server.")
613             break # Generate a generic failure
614
615     if not saw_the_dot_arg:
616         die("invalid rsync-command syntax or options")
617
618     if args.dir != '/' and not am_sender:
619         # A restricted dir denies device/special CREATION, but `-a` (-rlptgoD)
620         # bundles -D into the client's short-option string, so rejecting -D
621         # outright would break every `rsync -a` to a restricted rrsync.
622         #
623         # --no-D cannot do this job: preserve_devices/preserve_specials also
624         # frame the file list's rdev fields, and only this end of the
625         # connection gets the option, so the client's -D sender writes rdev
626         # that a --no-D receiver never reads.  That desynchronises the list --
627         # a FIFO hangs the transfer at protocol 29 and corrupts it at 30, a
628         # device node breaks EVERY protocol.  --drop-D refuses the creation
629         # while leaving the wire format alone.  (Needs rsync 3.5.0+, which is
630         # what rrsync is installed alongside.)
631         #
632         # Only on the receiving side: creation happens where files are
633         # written, so a sender has nothing to deny -- --drop-D would be a
634         # no-op there.
635         rsync_opts.append('--drop-D')
636
637     if args.dir != '/':
638         # Filter rules travel over the protocol, not in the argv we validate, so
639         # a client can name a merge file outside the restricted dir and have the
640         # server read it in as rules -- a pull needs no --delete and no
641         # verbosity for that.  --confine-root bounds the server's own resolution
642         # of such paths, which is the only end that can see them.  Both
643         # directions: a dir-merge is read by whichever side the rule applies to.
644         rsync_opts.append('--confine-root=' + os.getcwd())
645
646     if args.munge:
647         rsync_opts.append('--munge-links')
648     
649     if args.no_overwrite:
650       rsync_opts.append('--ignore-existing')
651
652     if not rsync_args:
653         rsync_args = [ '.' ]
654
655     cmd = (RSYNC, *rsync_opts, '--', '.', *rsync_args)
656
657     if log_fh:
658         now = time.localtime()
659         host = os.environ.get('SSH_CONNECTION', 'unknown').split()[0] # Drop everything after the IP addr
660         if host.startswith('::ffff:'):
661             host = host[7:]
662         try:
663             host = socket.gethostbyaddr(socket.inet_aton(host))
664         except:
665             pass
666         log_fh.write("%02d:%02d:%02d %-16s %s\n" % (now.tm_hour, now.tm_min, now.tm_sec, host, str(cmd)))
667         log_fh.close()
668
669     # NOTE: This assumes that the rsync protocol will not be maliciously hijacked.
670     if args.no_lock:
671         os.execlp(RSYNC, *cmd)
672         die("execlp(", RSYNC, *cmd, ')  failed')
673     # pass_fds keeps the inode-pinning O_PATH fds open across the spawn so
674     # /proc/self/fd/N in the cmd resolves correctly in the child. See the
675     # pinned_fds comment near the top.
676     child = subprocess.run(cmd, pass_fds=tuple(pinned_fds))
677     if child.returncode != 0:
678         sys.exit(child.returncode)
679
680
681 def validated_arg(opt, arg, typ=3, wild=False):
682     if opt != 'arg': # arg values already have their backslashes removed.
683         arg = DE_BACKSLASH_RE.sub(r'\1', arg)
684
685     # "-" is rsync's read-the-list-from-stdin sentinel, not a pathname: a pull
686     # with a local --files-from sends exactly "--files-from=-" to the server.
687     if opt == '--files-from':
688         if arg == '-':
689             return arg
690         if args.wo:
691             die('a write-only server cannot read a remote --files-from path')
692
693     orig_arg = arg
694     if arg.startswith('./'):
695         arg = arg[1:]
696     arg = arg.replace('//', '/')
697     is_absolute_arg = args.absolute and opt == 'arg' and args.dir != '/' and (arg == args.dir or arg.startswith(args.dir_slash))
698     if not is_absolute_arg:
699         arg = arg.lstrip('/')
700     if args.dir != '/':
701         if HAS_DOT_DOT_RE.search(arg):
702             die("do not use .. in", opt, "(anchor the path at the root of your restricted dir)")
703
704     if wild:
705         got = glob.glob(arg)
706         if not got:
707             got = [ arg ]
708     else:
709         got = [ arg ]
710
711     ret = [ ]
712     for arg in got:
713         if args.dir != '/' and arg != '.' and (typ == 3 or (typ == 2 and not am_sender)):
714             arg_has_trailing_slash = arg.endswith('/')
715             arg_has_trailing_slash_dot = False
716             if arg_has_trailing_slash:
717                 arg = arg[:-1]
718             else:
719                 arg_has_trailing_slash_dot = arg.endswith('/.')
720                 if arg_has_trailing_slash_dot:
721                     arg = arg[:-2]
722             real_arg = os.path.realpath(arg)
723             if arg != real_arg and not real_arg.startswith(args.dir_slash):
724                 if not (is_absolute_arg and real_arg == args.dir):
725                     die('unsafe arg:', orig_arg, [arg, real_arg])
726             # Inode-pin the validated path so an attacker cannot flip a
727             # path component AFTER realpath validates it but BEFORE the
728             # exec'd rsync resolves it.
729             #
730             # CRITICAL: open with O_RDONLY (not O_PATH).  An O_PATH fd
731             # holds a path/dentry reference and /proc/self/fd/N for an
732             # O_PATH fd re-resolves the path on open -- which means the
733             # race window stays open across the exec.  A regular
734             # O_RDONLY fd holds an open file (inode-bound), and
735             # /proc/self/fd/N for a regular fd references the inode
736             # directly -- exactly the race-closing primitive we need.
737             #
738             # O_NOFOLLOW on this open means a symlink that raced into
739             # place between realpath and this open is refused at the
740             # leaf.  A subsequent fstat() + readlink-of-fd verifies the
741             # pinned inode is still within the restricted tree (a
742             # parent-component race that landed on an in-tree symlink
743             # but outside-tree target would surface here).
744             #
745             # /proc/self/fd/N then routes the exec'd rsync's open
746             # through the kernel's magic link to the SAME pinned inode
747             # regardless of any subsequent flip; the race is closed.
748             #
749             # Linux-only (O_PATH/proc trick is Linux specific); on
750             # non-Linux fall through to the unhardened path.  For paths
751             # that don't exist yet (receiver-side new dest) os.open
752             # fails -- we skip pinning there; the new-dest race is a
753             # separate concern.
754             # Only a regular file or directory gets its CONTENT opened.  A
755             # sender needs neither for anything else: rsync transmits a symlink
756             # by its target string and skips a device/FIFO/socket under the
757             # forced --no-D.  Opening them here is also actively wrong --
758             # O_RDONLY on a FIFO blocks until a writer appears, so naming an
759             # in-tree FIFO wedged rrsync before exec, and a dangling symlink
760             # resolved to a missing target and was reported as a race.  3.4.4
761             # transfers both.  These shapes take the parent pin, which is what
762             # confines them anyway.
763             # NOT for a trailing "/" or "/." argument: rsync opens that one and
764             # DOES follow a symlink there, so its leaf pin is load-bearing --
765             # rrsync-sender-leaf-flip proves a raced flip leaks the outside
766             # directory's content without it.
767             sender_leaf_unopened = False
768             # Not gated on HAVE_PROC_SELF_FD: this is a decision about what
769             # rsync does with the argument, not about whether we can pin it, so
770             # it has to hold on the BSDs, macOS, Solaris and Cygwin too -- where
771             # otherwise a dangling symlink still resolved to nothing and died.
772             if (am_sender and opt == 'arg'
773                     and not arg_has_trailing_slash
774                     and not arg_has_trailing_slash_dot):
775                 try:
776                     lst = os.lstat(arg)
777                 except OSError:
778                     lst = None
779                 if lst is not None and not (stat.S_ISREG(lst.st_mode)
780                                             or stat.S_ISDIR(lst.st_mode)):
781                     sender_leaf_unopened = True
782             try:
783                 if sender_leaf_unopened:
784                     raise InterruptedError()   # jump to the sender-pin branch
785                 try:
786                     # O_NONBLOCK so a special file that raced in after the
787                     # lstat above still cannot block this open.
788                     fd = os.open(real_arg,
789                                  os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
790                 except IsADirectoryError:
791                     fd = os.open(real_arg,
792                                  os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
793             except InterruptedError:
794                 # No CONTENT fd for this leaf -- but it is still named
795                 # beneath a pinned directory below, not re-resolved from
796                 # the tree root.
797                 fd = None
798             except FileNotFoundError:
799                 # In --sender mode the path MUST exist (we're reading
800                 # from it) -- ENOENT here means the rename-based race
801                 # caught a transient gap in the flipper's swap.  Die.
802                 if am_sender:
803                     die('post-realpath open failed (race detected):',
804                         orig_arg, 'No such file or directory')
805                 if opt in MUST_EXIST_DIR_OPTS:
806                     die('receiver option path does not exist:', orig_arg)
807                 # Receiver-side new destination: the leaf has no inode to pin
808                 # yet, but pin its existing PARENT directory and route the
809                 # exec'd rsync's creation through /proc/self/fd/<parent>/<leaf>,
810                 # so a parent-component flip after realpath can't redirect the
811                 # new file/dir out of the tree.  Linux-only (the /proc magic
812                 # link); elsewhere, or if the parent itself doesn't exist yet
813                 # (a deeper -R new path), fall through unpinned as before.
814                 fd = None
815                 leaf = os.path.basename(real_arg)
816                 if opt in CREATE_DIR_MODE or opt in EMPTY_BASIS_OPTS:
817                     # An auxiliary directory the peer can supply mid-transfer.
818                     # The parent-pinned spelling below is not enough for these:
819                     # the same transfer can plant a symlink at <leaf> first,
820                     # and rsync would create or read through it, outside the
821                     # tree.  Both answers below hand rsync an inode instead of
822                     # a name, so without that primitive there is no safe way to
823                     # proceed -- refuse rather than pass the name through.
824                     if not HAVE_PROC_SELF_FD:
825                         die('receiver option path does not exist:', orig_arg)
826                     if opt in CREATE_DIR_MODE:
827                         if not leaf or leaf in ('.', '..'):
828                             die('bad receiver option path:', orig_arg)
829                         arg = create_pinned_dir(real_arg, orig_arg,
830                                                 CREATE_DIR_MODE[opt])
831                     else:
832                         arg = pinned_empty_dir(orig_arg)
833                 elif HAVE_PROC_SELF_FD and leaf and leaf not in ('.', '..'):
834                     try:
835                         pfd = os.open(os.path.dirname(real_arg) or '/',
836                                       os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
837                     except OSError:
838                         pfd = -1
839                     if pfd >= 0:
840                         try:
841                             ppath = os.readlink('/proc/self/fd/%d' % pfd)
842                         except OSError as e:
843                             os.close(pfd)
844                             die('post-pin readlink failed (race?):',
845                                 orig_arg, e.strerror)
846                         # The pinned parent must be the tree root or under it.
847                         if ppath != args.dir and not ppath.startswith(args.dir_slash):
848                             os.close(pfd)
849                             die('post-pin path escaped tree (race?):',
850                                 orig_arg, ppath)
851                         os.set_inheritable(pfd, True)
852                         pinned_fds.append(pfd)
853                         arg = '/proc/self/fd/%d/%s' % (pfd, leaf)
854             except OSError as e:
855                 # ELOOP or anything else is a race signal: realpath
856                 # validated the path moments ago, but the open just
857                 # failed -- something flipped between the check and
858                 # the pin (typically a symlink-flip on the leaf).
859                 die('post-realpath open failed (race detected):',
860                     orig_arg, e.strerror)
861             if am_sender and opt == 'arg':
862                 logical = arg
863                 if is_absolute_arg:
864                     if logical == args.dir:
865                         logical = ''
866                     elif logical.startswith(args.dir_slash):
867                         logical = logical[args.dir_slash_len:]
868             if fd is None and sender_leaf_unopened:
869                 # A leaf we deliberately never opened is still spelled beneath
870                 # a pinned directory: leaving the bare name for rsync to
871                 # re-resolve puts every component back in play, which is
872                 # CVE-2026-53783 -- measured at 3 leaks in 83 raced pulls with
873                 # a dangling-symlink leaf whose parent was flipped to point
874                 # outside the tree.  It costs nothing here: the directory is
875                 # opened O_PATH, so the special file itself is never opened
876                 # and a FIFO cannot block, and whatever the leaf turns into
877                 # afterwards is reached only from beneath the held one.
878                 #
879                 # WHICH directory is sender_pinned_arg()'s decision, and it is
880                 # the immediate parent for every shape EXCEPT a --relative
881                 # argument with no client "/./": there the whole argument is
882                 # the transmitted name, so only the anchor it starts from can
883                 # be pinned and the components below it stay raceable.  That
884                 # --relative limit predates this and is stated in NEWS.
885                 if HAVE_PROC_SELF_FD:
886                     pinned = sender_pinned_arg(None, logical, orig_arg,
887                                                arg_has_trailing_slash,
888                                                arg_has_trailing_slash_dot)
889                     if pinned != LEAF_PIN_UNUSABLE:
890                         arg = pinned
891             elif fd is not None:
892                 # The inode-pin trick (verify + route the exec'd rsync's open via
893                 # the /proc/self/fd magic link) is Linux-only.  Where /proc/self/fd
894                 # does not exist at all (the BSDs, Solaris, macOS, Cygwin, or a
895                 # /proc-less namespace) we cannot pin -- fall through to the
896                 # unhardened path (close the fd, keep the realpath-validated arg)
897                 # per the design note above.  But where /proc/self/fd DOES exist
898                 # (Linux), a readlink failure is an anomaly (sandbox/seccomp), not
899                 # a no-proc platform: fail CLOSED rather than silently unharden.
900                 if not HAVE_PROC_SELF_FD:
901                     os.close(fd)            # no /proc/self/fd: run unpinned
902                 else:
903                     try:
904                         pinned_path = os.readlink('/proc/self/fd/%d' % fd)
905                     except OSError as e:
906                         os.close(fd)
907                         die('post-pin readlink failed (race?):',
908                             orig_arg, e.strerror)
909                     # The pinned inode must live under args.dir_slash (or BE
910                     # args.dir).  Catches a parent-component flip that landed
911                     # inside an in-tree path but pointed outside.
912                     if (not pinned_path.startswith(args.dir_slash)
913                         and pinned_path != args.dir):
914                         os.close(fd)
915                         die('post-pin path escaped tree (race?):',
916                             orig_arg, pinned_path)
917                     if am_sender and opt == 'arg':
918                         pinned = sender_pinned_arg(fd, logical, orig_arg,
919                                                    arg_has_trailing_slash,
920                                                    arg_has_trailing_slash_dot)
921                     else:
922                         pinned = KEEP_LEAF_PIN
923                     if pinned == KEEP_LEAF_PIN:
924                         os.set_inheritable(fd, True)
925                         pinned_fds.append(fd)
926                         arg = '/proc/self/fd/%d' % fd
927                     elif pinned == LEAF_PIN_UNUSABLE:
928                         # Nothing to spell beneath a held directory (a bare "."
929                         # or the tree root): keep the realpath-validated name,
930                         # which is what 3.4.4 passes.
931                         os.close(fd)
932                     else:
933                         os.close(fd)    # only needed to validate the pin
934                         arg = pinned
935             if arg_has_trailing_slash:
936                 arg += '/'
937             elif arg_has_trailing_slash_dot:
938                 arg += '/.'
939             if is_absolute_arg and arg == args.dir:
940                 arg = '.'
941             elif opt == 'arg' and arg.startswith(args.dir_slash):
942                 arg = arg[args.dir_slash_len:]
943                 if arg == '':
944                     arg = '.'
945         ret.append(arg)
946
947     return ret if wild else ret[0]
948
949
950 def lock_or_die(dirname):
951     import fcntl, errno
952     global lock_handle
953     lock_handle = os.open(dirname, os.O_RDONLY)
954     try:
955         fcntl.flock(lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
956     except OSError as e:
957         if e.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
958             die('Another instance of rrsync is already accessing this directory.')
959         # flock() is unavailable on this fd/platform -- e.g. Solaris returns
960         # EBADF for flock() on a directory fd. The single-run lock is a
961         # best-effort convenience (cf. -no-lock), not a security control, so
962         # proceed without it rather than abort every transfer.
963         os.close(lock_handle)
964         lock_handle = None
965
966
967 def die(*msg):
968     print(sys.argv[0], 'error:', *msg, file=sys.stderr)
969     if sys.stdin.isatty():
970         arg_parser.print_help(sys.stderr)
971     sys.exit(1)
972
973
974 # This class displays the --help to the user on argparse error IFF they're running it interactively.
975 class OurArgParser(argparse.ArgumentParser):
976     def error(self, msg):
977         die(msg)
978
979
980 if __name__ == '__main__':
981     our_desc = """Use "man rrsync" to learn how to restrict ssh users to using a restricted rsync command."""
982     arg_parser = OurArgParser(description=our_desc, add_help=False)
983     only_group = arg_parser.add_mutually_exclusive_group()
984     only_group.add_argument('-ro', action='store_true', help="Allow only reading from the DIR. Implies -no-del and -no-lock.")
985     only_group.add_argument('-wo', action='store_true', help="Allow only writing to the DIR.")
986     arg_parser.add_argument('-munge', action='store_true', help="Enable rsync's --munge-links on the server side.")
987     arg_parser.add_argument('-absolute', action='store_true', help="Allow transfer args to use absolute server paths under DIR.")
988     arg_parser.add_argument('-no-del', action='store_true', help="Disable rsync's --delete* and --remove* options.")
989     arg_parser.add_argument('-no-lock', action='store_true', help="Avoid the single-run (per-user) lock check.")
990     arg_parser.add_argument('-no-overwrite', action='store_true', help="Prevent overwriting existing files by enforcing --ignore-existing")
991     arg_parser.add_argument('-help', '-h', action='help', help="Output this help message and exit.")
992     arg_parser.add_argument('dir', metavar='DIR', help="The restricted directory to use.")
993     args = arg_parser.parse_args()
994     args.dir = os.path.realpath(args.dir)
995     args.dir_slash = args.dir + '/'
996     args.dir_slash_len = len(args.dir_slash)
997     if args.ro:
998         args.no_del = True
999     elif not args.no_lock:
1000         lock_or_die(args.dir)
1001     main()
1002
1003 # vim: sw=4 et