From 708fce621371f7631d07ee37e9c75dfa0939a5db Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 00:04:33 +0300 Subject: [PATCH 01/25] ltfs: add --ltfs option and start-block file-list plumbing Introduce the machinery for tape-aware transfers without yet changing the read order. On an LTFS volume each file's metadata -- including the block where its data begins -- lives in the volume index and is cheap to read, while reading content requires physically positioning the tape. Add a new ltfs.c module that reads a file's starting block from the ltfs.startblock virtual xattr (also honoring a user.ltfs.startblock alias so the feature can be exercised on an ordinary filesystem). The sender records that block into a new optional 64-bit file-list extra and transmits it, so the generator -- which on a local copy is chdir'd into the destination and cannot read the source xattrs itself -- receives each file's physical position. Wire up the --ltfs option: it allocates the extra, implies --whole-file (a delta transfer would re-read the source off tape anyway), forces --no-inc-recursive (the full list is needed before a read order can be chosen), and refuses --checksum (which would read the whole tape just to decide what to transfer). The option is propagated to the server side so both ends agree on the file-list extra layout. Reading the start block requires xattr support, so the whole feature is gated on SUPPORT_XATTRS: the file-list extra is only registered when xattrs are available, and a build without them refuses --ltfs (like --crtimes and friends) rather than silently accepting an inert option. --- Makefile.in | 2 +- compat.c | 7 ++- flist.c | 14 ++++++ ltfs.c | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++ options.c | 30 ++++++++++++ rsync.h | 2 + 6 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 ltfs.c diff --git a/Makefile.in b/Makefile.in index 716171db1..33ed49166 100644 --- a/Makefile.in +++ b/Makefile.in @@ -45,7 +45,7 @@ LIBOBJ=lib/wildmatch.o lib/compat.o lib/snprintf.o lib/mdfour.o lib/md5.o \ zlib_OBJS=zlib/deflate.o zlib/inffast.o zlib/inflate.o zlib/inftrees.o \ zlib/trees.o zlib/zutil.o zlib/adler32.o zlib/compress.o zlib/crc32.o OBJS1_NO_MAIN=flist.o rsync.o generator.o receiver.o cleanup.o sender.o exclude.o \ - util1.o util2.o checksum.o match.o syscall.o log.o backup.o delete.o + util1.o util2.o checksum.o match.o syscall.o log.o backup.o delete.o ltfs.o OBJS1=$(OBJS1_NO_MAIN) main.o OBJS2=options.o io.o compat.o hlink.o token.o uidlist.o socket.o hashtable.o \ usage.o fileio.o batch.o clientname.o chmod.o acls.o xattrs.o diff --git a/compat.c b/compat.c index ba1b0c949..3a971c729 100644 --- a/compat.c +++ b/compat.c @@ -45,6 +45,7 @@ extern int preserve_uid; extern int preserve_gid; extern int preserve_atimes; extern int preserve_crtimes; +extern int ltfs_mode; extern int preserve_acls; extern int preserve_xattrs; extern int xfer_flags_as_varint; @@ -87,7 +88,7 @@ struct name_num_item *xattr_sum_nni; int xattr_sum_len = 0; /* These index values are for the file-list's extra-attribute array. */ -int pathname_ndx, depth_ndx, atimes_ndx, crtimes_ndx, uid_ndx, gid_ndx, acls_ndx, xattrs_ndx, unsort_ndx; +int pathname_ndx, depth_ndx, atimes_ndx, crtimes_ndx, startblock_ndx, uid_ndx, gid_ndx, acls_ndx, xattrs_ndx, unsort_ndx; int receiver_symlink_times = 0; /* receiver can set the time on a symlink */ int sender_symlink_iconv = 0; /* sender should convert symlink content */ @@ -584,6 +585,10 @@ void setup_protocol(int f_out,int f_in) atimes_ndx = (file_extra_cnt += EXTRA64_CNT); if (preserve_crtimes) crtimes_ndx = (file_extra_cnt += EXTRA64_CNT); +#ifdef SUPPORT_XATTRS + if (ltfs_mode) + startblock_ndx = (file_extra_cnt += EXTRA64_CNT); +#endif if (am_sender) /* This is most likely in the file_extras64 union as well. */ pathname_ndx = (file_extra_cnt += PTR_EXTRA_CNT); else diff --git a/flist.c b/flist.c index 9276c65fc..01876c024 100644 --- a/flist.c +++ b/flist.c @@ -62,6 +62,7 @@ extern int missing_args; extern int eol_nulls; extern int atimes_ndx; extern int crtimes_ndx; +extern int startblock_ndx; extern int relative_paths; extern int implied_dirs; extern int ignore_perishable; @@ -685,6 +686,8 @@ static void send_file_entry(int f, const char *fname, struct file_struct *file, if (crtimes_ndx && !(xflags & XMIT_CRTIME_EQ_MTIME)) write_varlong(f, crtime, 4); #endif + if (startblock_ndx) + write_varlong(f, F_STARTBLOCK(file), 3); if (!(xflags & XMIT_SAME_MODE)) write_int(f, to_wire_mode(mode)); if (atimes_ndx && !S_ISDIR(mode) && !(xflags & XMIT_SAME_ATIME)) @@ -780,6 +783,7 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x #ifdef SUPPORT_CRTIMES static time_t crtime; #endif + static int64 startblock; static mode_t mode; #ifdef SUPPORT_HARD_LINKS static int64 dev; @@ -964,6 +968,8 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x #endif } #endif + if (startblock_ndx) + startblock = read_varlong(f, 3); if (!(xflags & XMIT_SAME_MODE)) { mode = from_wire_mode(read_int(f)); /* Reject modes whose type bits are not one of the standard @@ -1215,6 +1221,8 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x if (crtimes_ndx) F_CRTIME(file) = crtime; #endif + if (startblock_ndx) + F_STARTBLOCK(file) = startblock; if (unsort_ndx) F_NDX(file) = flist->used + flist->ndx_start; @@ -1679,6 +1687,12 @@ struct file_struct *make_file(const char *fname, struct file_list *flist, if (crtimes_ndx) F_CRTIME(file) = get_create_time(fname, &st); #endif + if (startblock_ndx) { + int64 blk = am_sender && S_ISREG(file->mode) ? ltfs_startblock(fname) : -1; + /* Unknown sorts first; keep it non-negative so write_varlong + * stays compact (a real LTFS data block is well past block 0). */ + F_STARTBLOCK(file) = blk < 0 ? 0 : blk; + } if (basename != thisname) file->dirname = lastdir; diff --git a/ltfs.c b/ltfs.c new file mode 100644 index 000000000..4f302f972 --- /dev/null +++ b/ltfs.c @@ -0,0 +1,136 @@ +/* + * LTFS (Linear Tape File System) awareness for rsync. + * + * Copyright (C) 2026 Wayne Davison & contributors + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, visit the http://fsf.org website. + * + * --------------------------------------------------------------------------- + * + * On an LTFS-mounted tape every file's metadata -- name, size, mtime, and the + * physical block where its data begins -- lives in the tape index, which is + * served from memory once the cartridge is mounted. Reading that metadata is + * therefore cheap, so building rsync's file list from an LTFS source is fast. + * + * Reading file *content*, on the other hand, requires physically positioning + * the tape. rsync's normal name-sorted traversal bears no relation to the + * order in which bytes are laid down on the medium, so a restore that opens + * files in name order makes the drive seek back and forth ("shoe-shining"), + * turning a single forward streaming pass into hours of repositioning. + * + * This module lets the generator drive the data-read phase in physical + * (start-block) order instead. LTFS exposes each file's starting block as a + * virtual extended attribute; we read it during generation and hand the + * generator a permutation of the file list sorted by that block, so the tape + * streams forward in one pass. Entries whose start block is unknown + * (directories, symlinks, files not on tape) sort first, in their original + * order, which conveniently front-loads directory creation before the bulk + * data read begins. + */ + +#include "rsync.h" +#ifdef SUPPORT_XATTRS +#include "lib/sysxattrs.h" +#endif + +extern int ltfs_mode; +extern int startblock_ndx; + +/* Return the LTFS starting block of fname, or -1 if it cannot be determined. + * The attribute value is an ASCII decimal block number. */ +int64 ltfs_startblock(const char *fname) +{ +#ifdef SUPPORT_XATTRS + /* The virtual xattr names under which LTFS publishes a file's starting + * data block. The bare "ltfs.*" name is what an LTFS FUSE mount + * presents; the "user.ltfs.*" alias lets the feature be exercised on an + * ordinary filesystem (e.g. by the test suite) where only the "user." + * namespace is writable. */ + static const char *startblock_attrs[] = { + "ltfs.startblock", + "user.ltfs.startblock", + }; + char buf[32]; + unsigned int i; + + for (i = 0; i < sizeof startblock_attrs / sizeof startblock_attrs[0]; i++) { + ssize_t len = sys_lgetxattr(fname, startblock_attrs[i], buf, sizeof buf - 1); + if (len > 0) { + char *end; + int64 blk; + buf[len] = '\0'; + blk = (int64)strtoll(buf, &end, 10); + if (end != buf && blk >= 0) + return blk; + } + } +#else + (void)fname; +#endif + return -1; +} + +struct ltfs_ent { + int64 startblock; + int idx; /* index into flist->sorted[] */ +}; + +static int ltfs_ent_cmp(const void *a, const void *b) +{ + const struct ltfs_ent *ea = a, *eb = b; + + if (ea->startblock != eb->startblock) + return ea->startblock < eb->startblock ? -1 : 1; + /* Stable tie-break so unknown-block entries (all -1, e.g. directories) + * keep their original parent-before-child name ordering. */ + return ea->idx < eb->idx ? -1 : ea->idx > eb->idx ? 1 : 0; +} + +/* Build a tape-physical read order for the active range of flist. Returns a + * malloc'd array of (flist->high - flist->low + 1) entries, each an index into + * flist->sorted[], ordered by ascending LTFS start block. The caller iterates + * the returned array in place of the natural low..high sweep. Returns NULL + * (caller falls back to natural order) if ltfs_mode is off, no start-block + * metadata was negotiated, or the range is empty. */ +int *ltfs_build_order(struct file_list *flist) +{ + struct ltfs_ent *ents; + int *order; + int n, j, count; + + if (!ltfs_mode || !startblock_ndx || flist->high < flist->low) + return NULL; + + n = flist->high - flist->low + 1; + ents = new_array(struct ltfs_ent, n); + order = new_array(int, n); + + for (j = 0, count = 0; j < n; j++) { + struct file_struct *file = flist->sorted[flist->low + j]; + ents[count].idx = flist->low + j; + if (F_IS_ACTIVE(file) && S_ISREG(file->mode)) + ents[count].startblock = F_STARTBLOCK(file); + else + ents[count].startblock = -1; + count++; + } + + qsort(ents, count, sizeof ents[0], ltfs_ent_cmp); + + for (j = 0; j < count; j++) + order[j] = ents[j].idx; + + free(ents); + return order; +} diff --git a/options.c b/options.c index 0efd02eb0..87670c6db 100644 --- a/options.c +++ b/options.c @@ -117,6 +117,7 @@ int human_readable = 1; int recurse = 0; int mkpath_dest_arg = 0; int allow_inc_recurse = 1; +int ltfs_mode = 0; int xfer_dirs = -1; int am_daemon = 0; /* Set after a successful per-module chroot ("use chroot = yes") in @@ -632,6 +633,8 @@ static struct poptOption long_options[] = { {"no-r", 0, POPT_ARG_VAL, &recurse, 0, 0, 0 }, {"inc-recursive", 0, POPT_ARG_VAL, &allow_inc_recurse, 1, 0, 0 }, {"no-inc-recursive", 0, POPT_ARG_VAL, &allow_inc_recurse, 0, 0, 0 }, + {"ltfs", 0, POPT_ARG_VAL, <fs_mode, 1, 0, 0 }, + {"no-ltfs", 0, POPT_ARG_VAL, <fs_mode, 0, 0, 0 }, {"i-r", 0, POPT_ARG_VAL, &allow_inc_recurse, 1, 0, 0 }, {"no-i-r", 0, POPT_ARG_VAL, &allow_inc_recurse, 0, 0, 0 }, {"dirs", 'd', POPT_ARG_VAL, &xfer_dirs, 2, 0, 0 }, @@ -1105,6 +1108,11 @@ static void set_refuse_options(void) #ifndef SUPPORT_CRTIMES parse_one_refuse_match(0, "crtimes", list_end); #endif +#ifndef SUPPORT_XATTRS + /* --ltfs orders the read by each file's ltfs.startblock xattr, so it is + * meaningless (and would silently no-op) without xattr support. */ + parse_one_refuse_match(0, "ltfs", list_end); +#endif /* Now we use the descrip values to actually mark the options for refusal. */ for (op = long_options; op != list_end; op++) { @@ -2539,6 +2547,23 @@ int parse_arguments(int *argc_p, const char ***argv_p) bwlimit_writemax = 512; } + if (ltfs_mode) { + /* A delta read would only re-read the source file we must + * stream off the tape anyway, so force whole-file. */ + if (whole_file < 0) + whole_file = 1; + /* We need the complete file list before we can order the read + * by physical block, so incremental recursion is incompatible. */ + allow_inc_recurse = 0; + /* --checksum would read every byte of every file off the tape + * just to decide what to transfer, defeating the whole point. */ + if (always_checksum) { + snprintf(err_buf, sizeof err_buf, + "--checksum cannot be used with --ltfs (it would read the entire tape)\n"); + goto cleanup; + } + } + if (append_mode) { if (whole_file > 0) { snprintf(err_buf, sizeof err_buf, @@ -2931,6 +2956,11 @@ void server_options(char **args, int *argc_p) } else if (preserve_specials) args[ac++] = "--specials"; + /* The sender reads the start-block metadata and both sides must agree + * on the file-list extra layout, so tell the server side about --ltfs. */ + if (ltfs_mode) + args[ac++] = "--ltfs"; + /* The server side doesn't use our log-format, but in certain * circumstances they need to know a little about the option. */ if (stdout_format && am_sender) { diff --git a/rsync.h b/rsync.h index b15aa1af6..e9f09ff52 100644 --- a/rsync.h +++ b/rsync.h @@ -857,6 +857,7 @@ extern int file_extra_cnt; extern int inc_recurse; extern int atimes_ndx; extern int crtimes_ndx; +extern int startblock_ndx; extern int pathname_ndx; extern int depth_ndx; extern int uid_ndx; @@ -921,6 +922,7 @@ extern int file_sum_extra_cnt; #define F_NDX(f) REQ_EXTRA(f, unsort_ndx)->num #define F_ATIME(f) REQ_EXTRA64(f, atimes_ndx)->num #define F_CRTIME(f) REQ_EXTRA64(f, crtimes_ndx)->num +#define F_STARTBLOCK(f) REQ_EXTRA64(f, startblock_ndx)->num /* These items are per-entry optional: */ #define F_HL_GNUM(f) OPT_EXTRA(f, START_BUMP(f))->num /* non-dirs */ From d7356884e3317d3a92235974c315a37849f503b5 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 00:05:28 +0300 Subject: [PATCH 02/25] ltfs: drive the generator's read order by start block With the start block of every file now available on the generator side, order the data-read phase by ascending block instead of by name when --ltfs is in effect. The drive then makes a single forward streaming pass rather than seeking back and forth ("shoe-shining"), which on a real tape can cut a restore from hours to one pass. Entries with no start block (directories, symlinks, anything not on tape) sort first in their original order, which conveniently front-loads creation of the destination directory tree before the bulk data read begins. A NULL ordering (ltfs off, no metadata negotiated, or an empty range) falls back to the natural low..high sweep. --- generator.c | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/generator.c b/generator.c index 7e5ad60a3..039c5f836 100644 --- a/generator.c +++ b/generator.c @@ -33,6 +33,7 @@ extern int operator_path_resolve; extern int am_server; extern int am_daemon; extern int inc_recurse; +extern int ltfs_mode; extern int relative_paths; extern int implied_dirs; extern int keep_dirlinks; @@ -2716,6 +2717,7 @@ void check_for_finished_files(int itemizing, enum logcode code, int check_redo) void generate_files(int f_out, const char *local_name) { int i, ndx, next_loopchk = 0; + int *ltfs_order = NULL; char fbuf[MAXPATHLEN]; int itemizing; enum logcode code; @@ -2800,8 +2802,15 @@ void generate_files(int f_out, const char *local_name) change_local_filter_dir(fbuf, strlen(fbuf), F_DEPTH(fp)); } } + /* For an LTFS source, read the files in physical tape order + * (by start block) rather than name order, so the drive makes + * one forward streaming pass instead of seeking back and forth. + * ltfs_order maps the natural sweep position to a sorted[] index; + * a NULL result falls back to the natural low..high order. */ + ltfs_order = ltfs_mode ? ltfs_build_order(cur_flist) : NULL; for (i = cur_flist->low; i <= cur_flist->high; i++) { - struct file_struct *file = cur_flist->sorted[i]; + int si = ltfs_order ? ltfs_order[i - cur_flist->low] : i; + struct file_struct *file = cur_flist->sorted[si]; if (!F_IS_ACTIVE(file)) continue; @@ -2809,7 +2818,7 @@ void generate_files(int f_out, const char *local_name) if (unsort_ndx) ndx = F_NDX(file); else - ndx = i + cur_flist->ndx_start; + ndx = si + cur_flist->ndx_start; if (solo_file) strlcpy(fbuf, solo_file, sizeof fbuf); @@ -2828,6 +2837,9 @@ void generate_files(int f_out, const char *local_name) } } + if (ltfs_order) + free(ltfs_order); + if (!inc_recurse) { write_ndx(f_out, NDX_DONE); break; From 0f816f65a5a4e172e6431842d03ecb088b0066c1 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 00:11:36 +0300 Subject: [PATCH 03/25] ltfs: document --ltfs in the manpage Add the option summary line and a full description covering what LTFS ordering does, the options it implies (--whole-file, --no-inc-recursive) and refuses (--checksum), that the fast index metadata still drives the normal size+mtime quick check, and that only the read (restore) direction is optimized. --- rsync.1.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/rsync.1.md b/rsync.1.md index ad686b646..fa30975d3 100644 --- a/rsync.1.md +++ b/rsync.1.md @@ -544,6 +544,7 @@ has its own detailed description later in this manpage. --inc-recursive, --i-r enable incremental recursion --no-inc-recursive disable incremental recursion --no-i-r same as --no-inc-recursive +--ltfs read an LTFS-tape source in physical block order --relative, -R use relative path names --no-implied-dirs don't send implied directories with --relative --backup, -b make backups (see --suffix & --backup-dir) @@ -1036,6 +1037,37 @@ sign) if you want the local shell to expand it. before it begins to transfer files. See [`--inc-recursive`](#opt) for more info. +0. `--ltfs` + + Optimize reading from a source that lives on an LTFS (Linear Tape File + System) volume. On tape, a file's metadata (name, size, modify time, and + the block where its data begins) is held in the volume index and is cheap + to read, but reading file *content* requires physically positioning the + tape. rsync's normal name-sorted order bears no relation to the physical + layout, so a restore seeks back and forth ("shoe-shining") and can take + many times longer than a single streaming pass. + + With `--ltfs`, rsync reads each file's starting block from the + `ltfs.startblock` virtual extended attribute and drives the transfer in + ascending block order, so the drive makes one forward pass. Files whose + start block is unknown (directories, symlinks, anything not on tape) are + handled first, which conveniently creates the destination directory tree + before the bulk data read begins. + + Because the whole point is to avoid re-reading tape data, this option + implies [`--whole-file`](#opt) (a delta transfer would re-read the source + file anyway) and forces [`--no-inc-recursive`](#opt) (the complete file + list is needed before the read order can be chosen). It also refuses + [`--checksum`](#opt), which would read every byte of every file off the + tape just to decide what to transfer. The fast index metadata still drives + the normal quick check (size & modify time), so unchanged files are skipped + without touching their data. + + This option only affects reading the source; writing a transfer *onto* an + LTFS volume is not currently optimized. It requires a build with extended + attribute support and the start-block ordering only takes effect when the + source files expose the `ltfs.startblock` attribute. + 0. `--relative`, `-R` Use relative paths. This means that the full path names specified on the From 50b0b61728a34a2ed14fe6d3c9d807465582e8c7 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 00:11:36 +0300 Subject: [PATCH 04/25] ltfs: add testsuite coverage for --ltfs Exercise --ltfs on an ordinary filesystem via the user.ltfs.startblock alias, assigning blocks that run opposite to name order. The test verifies round-trip integrity, that the itemized output (rsync's observable processing order) comes out in physical block order across subdirectories with directories handled first, and that --ltfs --checksum is refused. Skips cleanly when the build lacks xattr support or the scratch filesystem rejects a user.* xattr. --- testsuite/ltfs_test.py | 97 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 testsuite/ltfs_test.py diff --git a/testsuite/ltfs_test.py b/testsuite/ltfs_test.py new file mode 100644 index 000000000..87fc7c1d1 --- /dev/null +++ b/testsuite/ltfs_test.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# Test rsync's --ltfs mode (LTFS / tape-aware read ordering). +# +# On a real LTFS mount each file's starting data block is published as the +# "ltfs.startblock" virtual xattr; --ltfs reads files in ascending start-block +# order so the tape streams forward in one pass instead of seeking back and +# forth in name order. We can't mount a tape here, so we stand in the +# "user.ltfs.startblock" alias (which the feature also honors) on an ordinary +# filesystem and assign blocks that run opposite to name order. The generator +# processes files in its read order and -i emits one itemized line per file in +# that same order, so the itemized output is our observable proxy for the +# physical read schedule. + +import os +import re + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, TODIR, + checkit, makepath, run_rsync, test_fail, test_skipped, +) + + +# The feature needs a build with xattr support... +vv = run_rsync('-VV', check=True, capture_output=True) +if '"xattrs": true' not in vv.stdout: + test_skipped("Rsync is configured without xattrs support") + +# ...and a scratch filesystem that lets us set a user.* xattr to stand in for +# the tape's ltfs.startblock attribute. +makepath(FROMDIR) +probe = FROMDIR / '.xattr-probe' +probe.write_text('x') +try: + os.setxattr(str(probe), 'user.ltfs.startblock', b'0') +except OSError as e: + test_skipped(f"scratch filesystem does not support user xattrs: {e}") +probe.unlink() + + +def set_block(path, block): + os.setxattr(str(path), 'user.ltfs.startblock', str(block).encode()) + + +# --- 1. round-trip integrity ----------------------------------------------- +# Five files whose start blocks run opposite to name order. +flat = {'alpha': 500, 'bravo': 400, 'charlie': 300, 'delta': 200, 'echo': 100} +for name, blk in flat.items(): + f = FROMDIR / f'{name}.dat' + f.write_text(f'content of {name}\n') + set_block(f, blk) + +# --ltfs must produce a byte-identical destination tree. +checkit(['-r', '--ltfs', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) + + +# --- 2. physical read order, across subdirectories ------------------------- +# The itemized output (one line per file, in generator processing order) must +# come out in ascending start-block order regardless of name/directory order, +# and directories (no start block) must be handled before the bulk data read. +src2 = SCRATCHDIR / 'from2' +dst2 = SCRATCHDIR / 'to2' +makepath(src2 / 'asub', src2 / 'zsub') + +layout = { + 'zsub/low.dat': 150, + 'zsub/mid.dat': 300, + 'top.dat': 600, + 'asub/high.dat': 900, +} +for rel, blk in layout.items(): + f = src2 / rel + f.write_text(f'block {blk}\n') + set_block(f, blk) + +res = run_rsync('-r', '-i', '--ltfs', f'{src2}/', f'{dst2}/', + check=True, capture_output=True) + +# Pull the per-file itemized lines (a leading ">f"/"cf"/etc. transfer code) +# in the order rsync emitted them. +got = re.findall(r'^[<>ch.][fdLDS]\S*\s+(\S+\.dat)$', res.stdout, re.MULTILINE) +expected = [rel for rel, _ in sorted(layout.items(), key=lambda kv: kv[1])] +if got != expected: + test_fail(f"--ltfs read order was {got}, expected tape order {expected}") + +# And the content must still be correct. +checkit(['-r', '--ltfs', f'{src2}/', f'{dst2}/'], src2, dst2) + + +# --- 3. --checksum is refused ---------------------------------------------- +# It would read every byte of every file off the tape just to decide what to +# transfer, defeating the point, so it must error rather than be honored. +res = run_rsync('-r', '--ltfs', '--checksum', f'{FROMDIR}/', f'{TODIR}/', + check=False, capture_output=True) +if res.returncode == 0: + test_fail("--ltfs --checksum was accepted; expected a usage error") +if 'checksum' not in (res.stderr + res.stdout): + test_fail(f"--ltfs --checksum gave an unexpected error: {res.stderr!r}") From 64a11f9e1210f5064f0e5df9e9fecbbd026784fa Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 11:38:48 +0300 Subject: [PATCH 05/25] ltfs: imply -t so the index quick-check can skip unchanged files --ltfs's value is that an LTFS source serves size and mtime from the tape index for free, letting rsync's quick check skip unchanged files without reading their content off the tape. That only works if the destination keeps the source mtimes: without -t, every run sees a time mismatch and re-reads the whole tape, defeating the purpose. Process --ltfs in the option loop (via OPT_LTFS) and set preserve_mtimes there, the same way --archive implies -t, so a later --no-times can still override it in option order. When it is overridden, warn that unchanged files can no longer be skipped, rather than silently doing the slow thing. Found while testing against a real LTO-5 LTFS volume: a bare -r --ltfs left current mtimes on the destination, so the next run wanted to re-read every file. --- options.c | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/options.c b/options.c index 87670c6db..e26605eec 100644 --- a/options.c +++ b/options.c @@ -604,7 +604,7 @@ enum {OPT_SERVER = 1000, OPT_DAEMON, OPT_SENDER, OPT_EXCLUDE, OPT_EXCLUDE_FROM, OPT_NO_D, OPT_APPEND, OPT_NO_ICONV, OPT_INFO, OPT_DEBUG, OPT_BLOCK_SIZE, OPT_USERMAP, OPT_GROUPMAP, OPT_CHOWN, OPT_BWLIMIT, OPT_STDERR, OPT_OLD_COMPRESS, OPT_NEW_COMPRESS, OPT_NO_COMPRESS, OPT_OLD_ARGS, - OPT_STOP_AFTER, OPT_STOP_AT, + OPT_STOP_AFTER, OPT_STOP_AT, OPT_LTFS, OPT_REFUSED_BASE = 9000}; static struct poptOption long_options[] = { @@ -633,7 +633,7 @@ static struct poptOption long_options[] = { {"no-r", 0, POPT_ARG_VAL, &recurse, 0, 0, 0 }, {"inc-recursive", 0, POPT_ARG_VAL, &allow_inc_recurse, 1, 0, 0 }, {"no-inc-recursive", 0, POPT_ARG_VAL, &allow_inc_recurse, 0, 0, 0 }, - {"ltfs", 0, POPT_ARG_VAL, <fs_mode, 1, 0, 0 }, + {"ltfs", 0, POPT_ARG_NONE, 0, OPT_LTFS, 0, 0 }, {"no-ltfs", 0, POPT_ARG_VAL, <fs_mode, 0, 0, 0 }, {"i-r", 0, POPT_ARG_VAL, &allow_inc_recurse, 1, 0, 0 }, {"no-i-r", 0, POPT_ARG_VAL, &allow_inc_recurse, 0, 0, 0 }, @@ -1684,6 +1684,14 @@ int parse_arguments(int *argc_p, const char ***argv_p) preserve_devices = preserve_specials = 0; break; + case OPT_LTFS: + ltfs_mode = 1; + /* Imply -t (like --archive does) so the index quick-check can + * skip unchanged files on a later run. Processed here in option + * order so a subsequent --no-times can still override it. */ + preserve_mtimes = 1; + break; + case 'h': human_readable++; break; @@ -2562,6 +2570,13 @@ int parse_arguments(int *argc_p, const char ***argv_p) "--checksum cannot be used with --ltfs (it would read the entire tape)\n"); goto cleanup; } + /* --ltfs implies -t (see OPT_LTFS); a 0 here means the user added an + * explicit --no-times afterward. We honor it, but warn: without + * preserved mtimes the index quick-check can't skip unchanged files, + * so every run re-reads the whole tape. */ + if (!preserve_mtimes && !am_server) + rprintf(FWARNING, + "--ltfs with --no-times: unchanged files cannot be skipped by mtime, so every run re-reads the tape.\n"); } if (append_mode) { From 38d132847e9c2fc306b86e8ad942829624264ebd Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 11:38:48 +0300 Subject: [PATCH 06/25] ltfs: document that --ltfs implies --times Note in the manpage that --ltfs enables mtime preservation (like --archive) so the index quick-check can skip unchanged files across runs, and that a later --no-times overrides it with a warning. --- rsync.1.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/rsync.1.md b/rsync.1.md index fa30975d3..59f7a4c07 100644 --- a/rsync.1.md +++ b/rsync.1.md @@ -1059,9 +1059,14 @@ sign) if you want the local shell to expand it. file anyway) and forces [`--no-inc-recursive`](#opt) (the complete file list is needed before the read order can be chosen). It also refuses [`--checksum`](#opt), which would read every byte of every file off the - tape just to decide what to transfer. The fast index metadata still drives - the normal quick check (size & modify time), so unchanged files are skipped - without touching their data. + tape just to decide what to transfer. + + The fast index metadata still drives the normal quick check (size & modify + time), so unchanged files are skipped without touching their data. For + that to work across runs the destination must keep the source mtimes, so + `--ltfs` also implies [`--times`](#opt) (like [`--archive`](#opt) does); a + later `--no-times` overrides it but triggers a warning, since without + preserved mtimes every run re-reads the whole tape. This option only affects reading the source; writing a transfer *onto* an LTFS volume is not currently optimized. It requires a build with extended From 475d70407eef416fca882ceb8a6ba8488898ffcd Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 11:38:48 +0300 Subject: [PATCH 07/25] ltfs: test the implied -t and the --no-times warning Verify that --ltfs preserves source mtimes without an explicit -t (so an immediate re-run finds nothing to transfer) and that an explicit --no-times still runs but emits a warning. --- testsuite/ltfs_test.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/testsuite/ltfs_test.py b/testsuite/ltfs_test.py index 87fc7c1d1..afd22bfe8 100644 --- a/testsuite/ltfs_test.py +++ b/testsuite/ltfs_test.py @@ -11,6 +11,7 @@ # that same order, so the itemized output is our observable proxy for the # physical read schedule. +import datetime import os import re @@ -95,3 +96,30 @@ def set_block(path, block): test_fail("--ltfs --checksum was accepted; expected a usage error") if 'checksum' not in (res.stderr + res.stdout): test_fail(f"--ltfs --checksum gave an unexpected error: {res.stderr!r}") + + +# --- 4. --ltfs implies -t so the index quick-check can skip files ----------- +# Pin a known old mtime on the source; --ltfs (no explicit -t) must preserve +# it on the destination, so an immediate re-run finds nothing to transfer. +old = datetime.datetime(2008, 1, 1, 12, 0, 0).timestamp() +src4 = SCRATCHDIR / 'from4' +dst4 = SCRATCHDIR / 'to4' +makepath(src4) +f4 = src4 / 'pinned.dat' +f4.write_text('pinned\n') +set_block(f4, 100) +os.utime(f4, (old, old)) + +run_rsync('-r', '--ltfs', f'{src4}/', f'{dst4}/', check=True) +if abs((dst4 / 'pinned.dat').stat().st_mtime - old) > 1: + test_fail("--ltfs did not preserve mtime (expected an implied -t)") +res = run_rsync('-r', '-i', '--ltfs', f'{src4}/', f'{dst4}/', + check=True, capture_output=True) +if 'pinned.dat' in res.stdout: + test_fail(f"--ltfs re-transferred an unchanged file: {res.stdout!r}") + +# An explicit --no-times defeats that, so it must warn (but still run). +res = run_rsync('-r', '--ltfs', '--no-times', f'{src4}/', f'{dst4}/', + check=True, capture_output=True) +if 'ltfs' not in (res.stderr + res.stdout).lower(): + test_fail(f"--ltfs --no-times did not warn: {res.stderr!r}") From ed3686cae52c28daf21d73e4373990f0b0a0610e Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 14:35:57 +0100 Subject: [PATCH 08/25] Skip LTFS aware test on platforms where xattrs do not work --- testsuite/ltfs_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testsuite/ltfs_test.py b/testsuite/ltfs_test.py index afd22bfe8..705a8081c 100644 --- a/testsuite/ltfs_test.py +++ b/testsuite/ltfs_test.py @@ -28,6 +28,8 @@ # ...and a scratch filesystem that lets us set a user.* xattr to stand in for # the tape's ltfs.startblock attribute. +if not hasattr(os, 'setxattr'): + test_skipped("os.setxattr not available on this platform") makepath(FROMDIR) probe = FROMDIR / '.xattr-probe' probe.write_text('x') From 04414ea511c575b4e5cf38463cefd20d91974c20 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 13:36:06 +0100 Subject: [PATCH 09/25] Added LTFS test to expected test skip lists for mac and cygwin --- testsuite/skiplist/cygwin.txt | 1 + testsuite/skiplist/macos.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/testsuite/skiplist/cygwin.txt b/testsuite/skiplist/cygwin.txt index 7cdbac732..95185fba5 100644 --- a/testsuite/skiplist/cygwin.txt +++ b/testsuite/skiplist/cygwin.txt @@ -41,6 +41,7 @@ filter-merge-symlink insecure-links-admin-optout link-dest-symlink-enotsup # the ENOTSUP hard-link hook is an LD_PRELOAD, Linux-only log-file-symlink +ltfs msg-io-timeout-overflow nondaemon-symlink-race nonroot-restrictive-perms diff --git a/testsuite/skiplist/macos.txt b/testsuite/skiplist/macos.txt index 3ec3c6b04..9b8223aca 100644 --- a/testsuite/skiplist/macos.txt +++ b/testsuite/skiplist/macos.txt @@ -18,6 +18,7 @@ daemon-max-alloc-zero dir-sgid fake-super-acl-xattr link-dest-symlink-enotsup # the ENOTSUP hard-link hook is an LD_PRELOAD, Linux-only +ltfs open-noatime partial-protected-regular-retry-linux preallocate From 736ddf0655a30eaaad690d35db11f77bd4fbc474 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 13:59:19 +0100 Subject: [PATCH 10/25] ltfs: introduce SUPPORT_LTFS; let --ltfs forward to a capable server Add SUPPORT_LTFS to rsync.h, defined whenever SUPPORT_XATTRS is present, as a named compile-time capability for the feature. Remove the parse_one_refuse_match() call that hard-refused --ltfs on non-xattr builds. Instead, move startblock_ndx allocation out of its #ifdef SUPPORT_XATTRS guard so both sides of a connection always agree on the file-list layout when ltfs_mode is on. A server-side runtime check (#ifndef SUPPORT_LTFS) aborts with "--ltfs is not supported on this server" if the server lacks the capability. This lets a client without extended attribute support accept --ltfs and forward it to a remote server that does have SUPPORT_LTFS, while ensuring a non-capable server fails loudly rather than silently. --- compat.c | 2 -- options.c | 12 +++++++----- rsync.h | 4 ++++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/compat.c b/compat.c index 3a971c729..680cfe4c2 100644 --- a/compat.c +++ b/compat.c @@ -585,10 +585,8 @@ void setup_protocol(int f_out,int f_in) atimes_ndx = (file_extra_cnt += EXTRA64_CNT); if (preserve_crtimes) crtimes_ndx = (file_extra_cnt += EXTRA64_CNT); -#ifdef SUPPORT_XATTRS if (ltfs_mode) startblock_ndx = (file_extra_cnt += EXTRA64_CNT); -#endif if (am_sender) /* This is most likely in the file_extras64 union as well. */ pathname_ndx = (file_extra_cnt += PTR_EXTRA_CNT); else diff --git a/options.c b/options.c index e26605eec..28496925b 100644 --- a/options.c +++ b/options.c @@ -1108,11 +1108,6 @@ static void set_refuse_options(void) #ifndef SUPPORT_CRTIMES parse_one_refuse_match(0, "crtimes", list_end); #endif -#ifndef SUPPORT_XATTRS - /* --ltfs orders the read by each file's ltfs.startblock xattr, so it is - * meaningless (and would silently no-op) without xattr support. */ - parse_one_refuse_match(0, "ltfs", list_end); -#endif /* Now we use the descrip values to actually mark the options for refusal. */ for (op = long_options; op != list_end; op++) { @@ -2555,6 +2550,13 @@ int parse_arguments(int *argc_p, const char ***argv_p) bwlimit_writemax = 512; } +#ifndef SUPPORT_LTFS + if (ltfs_mode && am_server) { + snprintf(err_buf, sizeof err_buf, + "--ltfs is not supported on this server\n"); + goto cleanup; + } +#endif if (ltfs_mode) { /* A delta read would only re-read the source file we must * stream off the tape anyway, so force whole-file. */ diff --git a/rsync.h b/rsync.h index e9f09ff52..7b823a45a 100644 --- a/rsync.h +++ b/rsync.h @@ -628,6 +628,10 @@ typedef unsigned int size_t; #define SUPPORT_CRTIMES 1 #endif +#ifdef SUPPORT_XATTRS +#define SUPPORT_LTFS 1 +#endif + /* Find a variable that is either exactly 32-bits or longer. * If some code depends on 32-bit truncation, it will need to * take special action in a "#if SIZEOF_INT32 > 4" section. */ From 1746cd5d7f6eb23e80c6832f4b7f209f35ff6803 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 16:17:50 +0300 Subject: [PATCH 11/25] ltfs: rename test to ltfs-local to reflect local-only scope The test exercises local-to-local copy ordering only; it does not test remote flag forwarding. Rename testsuite/ltfs_test.py to testsuite/ltfs-local_test.py, add a clarifying comment at the top of the script, and update the RSYNC_EXPECT_SKIPPED list in both the macOS and Cygwin CI workflows to match the new test base name. --- testsuite/{ltfs_test.py => ltfs-local_test.py} | 4 +++- testsuite/skiplist/cygwin.txt | 2 +- testsuite/skiplist/macos.txt | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) rename testsuite/{ltfs_test.py => ltfs-local_test.py} (96%) diff --git a/testsuite/ltfs_test.py b/testsuite/ltfs-local_test.py similarity index 96% rename from testsuite/ltfs_test.py rename to testsuite/ltfs-local_test.py index 705a8081c..3e3d5fe85 100644 --- a/testsuite/ltfs_test.py +++ b/testsuite/ltfs-local_test.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 -# Test rsync's --ltfs mode (LTFS / tape-aware read ordering). +# Test rsync's --ltfs mode: LOCAL copy behaviour and tape-aware read ordering. +# This test exercises only local-to-local file copying; it does NOT test +# forwarding --ltfs to a remote server. # # On a real LTFS mount each file's starting data block is published as the # "ltfs.startblock" virtual xattr; --ltfs reads files in ascending start-block diff --git a/testsuite/skiplist/cygwin.txt b/testsuite/skiplist/cygwin.txt index 95185fba5..7b04ecf95 100644 --- a/testsuite/skiplist/cygwin.txt +++ b/testsuite/skiplist/cygwin.txt @@ -41,7 +41,7 @@ filter-merge-symlink insecure-links-admin-optout link-dest-symlink-enotsup # the ENOTSUP hard-link hook is an LD_PRELOAD, Linux-only log-file-symlink -ltfs +ltfs-local msg-io-timeout-overflow nondaemon-symlink-race nonroot-restrictive-perms diff --git a/testsuite/skiplist/macos.txt b/testsuite/skiplist/macos.txt index 9b8223aca..b6bc2d2f0 100644 --- a/testsuite/skiplist/macos.txt +++ b/testsuite/skiplist/macos.txt @@ -18,7 +18,7 @@ daemon-max-alloc-zero dir-sgid fake-super-acl-xattr link-dest-symlink-enotsup # the ENOTSUP hard-link hook is an LD_PRELOAD, Linux-only -ltfs +ltfs-local open-noatime partial-protected-regular-retry-linux preallocate From 025646c0da1adb1ad9b7c7b42bab5f4fe0f0715e Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 15:00:52 +0100 Subject: [PATCH 12/25] ltfs: add server-reject test and fix the two bugs it exposed Two bugs prevented --ltfs from being rejected by a server that lacks SUPPORT_LTFS: 1. The abort guard in parse_arguments() checked only am_server, which is 0 in daemon mode (it is set only when --server appears in argv, i.e. remote-shell invocation). Daemon mode sets am_daemon instead, so the condition now reads am_server || am_daemon. 2. The test binary rsync_noltfs was compiled with -USUPPORT_XATTRS to suppress SUPPORT_LTFS, but config.h (which has no include guard and is re-included by rsync.h) redefines SUPPORT_XATTRS regardless of -U flags. The fix uses a positive compile-time hook: -DLTFS_SERVER_REJECT_TEST activates the abort block via #if !defined(SUPPORT_LTFS) || defined(LTFS_SERVER_REJECT_TEST) so the rejection path is always compiled into the test binary regardless of config.h. Makefile.in gains the rsync_noltfs build rules (options.c recompiled with the test hook; all other .o files shared with the normal rsync binary). The ltfs-server-reject test drives this: it starts rsync_noltfs as the daemon via RSYNC_CONNECT_PROG and verifies that the client gets a clear "--ltfs is not supported on this server" error. --- Makefile.in | 18 ++++++++-- options.c | 4 +-- testsuite/ltfs-server-reject_test.py | 51 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 testsuite/ltfs-server-reject_test.py diff --git a/Makefile.in b/Makefile.in index 33ed49166..e74c0aa05 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,18 +55,26 @@ popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ +# rsync_noltfs: identical to rsync but options.c recompiled with +# -DLTFS_SERVER_REJECT_TEST, which forces the server-side --ltfs rejection +# path active regardless of SUPPORT_LTFS. Used by the ltfs-server-reject test. +OBJS_NOLTFS=$(OBJS1) options_noltfs.o io.o compat.o hlink.o token.o uidlist.o \ + socket.o hashtable.o usage.o fileio.o batch.o clientname.o chmod.o acls.o \ + xattrs.o $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ + TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ # Programs we must have to run the test cases CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) t_chmod_secure$(EXEEXT) \ - t_rename_secure$(EXEEXT) t_symlink_secure$(EXEEXT) t_secure_relpath$(EXEEXT) t_acl$(EXEEXT) t_hashtable_overflow$(EXEEXT) t_iwildmatch$(EXEEXT) t_clean_fname$(EXEEXT) t_safe_arg$(EXEEXT) wildtest$(EXEEXT) simdtest$(EXEEXT) + t_rename_secure$(EXEEXT) t_symlink_secure$(EXEEXT) t_secure_relpath$(EXEEXT) t_acl$(EXEEXT) t_hashtable_overflow$(EXEEXT) t_iwildmatch$(EXEEXT) t_clean_fname$(EXEEXT) t_safe_arg$(EXEEXT) wildtest$(EXEEXT) simdtest$(EXEEXT) \ + rsync_noltfs$(EXEEXT) CHECK_SYMLINKS = testsuite/chown-fake_test.py testsuite/devices-fake_test.py \ testsuite/xattrs-hlink_test.py testsuite/exclude-lsh_test.py # Objects for CHECK_PROGS to clean -CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_rename_secure.o t_symlink_secure.o t_secure_relpath.o t_acl.o t_hashtable_overflow.o t_iwildmatch.o t_clean_fname.o t_safe_arg.o trimslash.o wildtest.o +CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_rename_secure.o t_symlink_secure.o t_secure_relpath.o t_acl.o t_hashtable_overflow.o t_iwildmatch.o t_clean_fname.o t_safe_arg.o trimslash.o wildtest.o options_noltfs.o # Compile-only feature-shape checks. CHECK_COMPILE_OBJS=syscall-no-at-fdcwd.o @@ -550,6 +558,12 @@ simdtest$(EXEEXT): simd-checksum-x86_64.cpp $(HEADERS) touch $@; \ fi +options_noltfs.o: $(srcdir)/options.c $(HEADERS) + $(CC) -I. -I$(srcdir) $(CFLAGS) $(CPPFLAGS) -DLTFS_SERVER_REJECT_TEST -c -o $@ $(srcdir)/options.c + +rsync_noltfs$(EXEEXT): $(OBJS_NOLTFS) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(OBJS_NOLTFS) $(LIBS) + testsuite/chown-fake_test.py: ln -s chown_test.py $(srcdir)/testsuite/chown-fake_test.py diff --git a/options.c b/options.c index 28496925b..69c3baded 100644 --- a/options.c +++ b/options.c @@ -2550,8 +2550,8 @@ int parse_arguments(int *argc_p, const char ***argv_p) bwlimit_writemax = 512; } -#ifndef SUPPORT_LTFS - if (ltfs_mode && am_server) { +#if !defined(SUPPORT_LTFS) || defined(LTFS_SERVER_REJECT_TEST) + if (ltfs_mode && (am_server || am_daemon)) { snprintf(err_buf, sizeof err_buf, "--ltfs is not supported on this server\n"); goto cleanup; diff --git a/testsuite/ltfs-server-reject_test.py b/testsuite/ltfs-server-reject_test.py new file mode 100644 index 000000000..15a9b7448 --- /dev/null +++ b/testsuite/ltfs-server-reject_test.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# Test that a server built without SUPPORT_LTFS rejects --ltfs with a clear +# error instead of silently ignoring it or corrupting the transfer. +# +# rsync_noltfs is an otherwise-identical rsync binary with only options.c +# recompiled without SUPPORT_XATTRS, which suppresses SUPPORT_LTFS and +# activates the server-side abort guard in options.c. The connection uses a +# local pipe (RSYNC_CONNECT_PROG) so no TCP socket or real LTFS volume is +# needed, and the test runs on all platforms regardless of xattr support. + +import shutil +import subprocess + +from rsyncfns import ( + FROMDIR, TODIR, TOOLDIR, + build_rsyncd_conf, makepath, rsync_argv, + start_test_daemon, test_fail, test_skipped, +) + +DAEMON_PORT = 12896 + +noltfs_bin = shutil.which('rsync_noltfs', path=str(TOOLDIR)) +if noltfs_bin is None: + test_skipped(f"rsync_noltfs binary not found in TOOLDIR ({TOOLDIR})") + +makepath(FROMDIR, TODIR) +(FROMDIR / 'probe.txt').write_text('hello from the ltfs-server-reject test\n') + +conf = build_rsyncd_conf() +url = start_test_daemon(conf, DAEMON_PORT, rsync_cmd=noltfs_bin) + +res = subprocess.run( + rsync_argv('-r', '--ltfs', f'{FROMDIR}/', f'{url}test-to/'), + capture_output=True, text=True, +) + +if res.returncode == 0: + test_fail( + "--ltfs succeeded against a no-LTFS server; expected a non-zero exit" + ) + +combined = res.stderr + res.stdout +if '--ltfs is not supported on this server' not in combined: + test_fail( + f"--ltfs against a no-LTFS server exited {res.returncode} but did " + f"not produce the expected error message.\n" + f"stdout: {res.stdout!r}\nstderr: {res.stderr!r}" + ) + +print("ltfs-server-reject: no-LTFS server correctly refused --ltfs " + "with a clear error message") From d28852161620cda4c27f837318ff1538e82196cb Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 15:01:06 +0100 Subject: [PATCH 13/25] ltfs: add daemon-pipe ordering test ltfs-daemon_test.py exercises the full client-server path over a RSYNC_CONNECT_PROG pipe (no TCP socket, no real LTFS volume needed): 1. Round-trip integrity: files with user.ltfs.startblock xattrs pushed to a daemon module and received successfully with --ltfs. 2. Physical ordering: files with startblocks assigned in reverse name order; the -i output from the generator (daemon side) must list them in ascending startblock order, confirming that ltfs_build_order() runs correctly across the daemon pipe. The test skips on platforms where os.setxattr is unavailable (Cygwin, macOS without the xattr shim), so ltfs-daemon is added to RSYNC_EXPECT_SKIPPED in both CI workflows. --- testsuite/ltfs-daemon_test.py | 97 +++++++++++++++++++++++++++++++++++ testsuite/skiplist/cygwin.txt | 1 + testsuite/skiplist/macos.txt | 1 + 3 files changed, 99 insertions(+) create mode 100644 testsuite/ltfs-daemon_test.py diff --git a/testsuite/ltfs-daemon_test.py b/testsuite/ltfs-daemon_test.py new file mode 100644 index 000000000..5a835709f --- /dev/null +++ b/testsuite/ltfs-daemon_test.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# Test --ltfs tape-ordering end-to-end over a daemon pipe connection. +# +# Both sides of the connection are the same binary (SUPPORT_LTFS on both). +# This covers what ltfs-local_test.py cannot: startblock_ndx must be +# allocated identically on both sides of the pipe, startblock values must +# survive the file-list wire format, and ltfs_build_order() must run in the +# daemon-side generator and produce an ordering that is visible in the +# itemized output returned to the client. +# +# Files are assigned user.ltfs.startblock values in reverse name order. +# In a daemon push the generator runs on the daemon (receiver) side; it +# reorders requests so the client sender serves files by ascending startblock. +# The -i output echoed back to the client reflects that generator order. + +import os +import re + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, TOOLDIR, + build_rsyncd_conf, makepath, run_rsync, start_test_daemon, + test_fail, test_skipped, +) + +DAEMON_PORT = 12898 + +# --- skip guards (same as ltfs-local_test.py) -------------------------------- + +vv = run_rsync('-VV', check=True, capture_output=True) +if '"xattrs": true' not in vv.stdout: + test_skipped("rsync built without xattr support") + +if not hasattr(os, 'setxattr'): + test_skipped("os.setxattr not available on this platform") + +makepath(FROMDIR) +probe = FROMDIR / '.xattr-probe' +probe.write_text('x') +try: + os.setxattr(str(probe), 'user.ltfs.startblock', b'0') +except OSError as e: + test_skipped(f"scratch filesystem does not support user xattrs: {e}") +probe.unlink() + + +def set_block(path, block): + os.setxattr(str(path), 'user.ltfs.startblock', str(block).encode()) + + +# --- daemon setup ------------------------------------------------------------ + +conf = build_rsyncd_conf() +url = start_test_daemon(conf, DAEMON_PORT) + + +# --- 1. round-trip integrity over daemon pipe -------------------------------- + +flat = {'alpha': 500, 'bravo': 400, 'charlie': 300, 'delta': 200, 'echo': 100} +for name, blk in flat.items(): + f = FROMDIR / f'{name}.dat' + f.write_text(f'content of {name}\n') + set_block(f, blk) + +run_rsync('-r', '--ltfs', f'{FROMDIR}/', f'{url}test-to/', check=True) + + +# --- 2. physical read order preserved over the pipe -------------------------- +# Files in a subdirectory tree with startblocks in reverse name order; push to +# daemon and capture -i output to verify ascending startblock ordering. + +src2 = SCRATCHDIR / 'from2' +makepath(src2 / 'asub', src2 / 'zsub') + +layout = { + 'zsub/low.dat': 150, + 'zsub/mid.dat': 300, + 'top.dat': 600, + 'asub/high.dat': 900, +} +for rel, blk in layout.items(): + f = src2 / rel + f.write_text(f'block {blk}\n') + set_block(f, blk) + +res = run_rsync('-r', '-i', '--ltfs', f'{src2}/', f'{url}test-to/ordering/', + check=True, capture_output=True) + +got = re.findall(r'^[<>ch.][fdLDS]\S*\s+(\S+\.dat)$', res.stdout, re.MULTILINE) +expected = [rel for rel, _ in sorted(layout.items(), key=lambda kv: kv[1])] +if got != expected: + test_fail( + f"--ltfs read order over daemon pipe was {got!r}, " + f"expected tape order {expected!r}" + ) + +print("ltfs-daemon: round-trip integrity and tape ordering verified " + "over daemon pipe") diff --git a/testsuite/skiplist/cygwin.txt b/testsuite/skiplist/cygwin.txt index 7b04ecf95..52fa1103f 100644 --- a/testsuite/skiplist/cygwin.txt +++ b/testsuite/skiplist/cygwin.txt @@ -41,6 +41,7 @@ filter-merge-symlink insecure-links-admin-optout link-dest-symlink-enotsup # the ENOTSUP hard-link hook is an LD_PRELOAD, Linux-only log-file-symlink +ltfs-daemon ltfs-local msg-io-timeout-overflow nondaemon-symlink-race diff --git a/testsuite/skiplist/macos.txt b/testsuite/skiplist/macos.txt index b6bc2d2f0..5dbe019fe 100644 --- a/testsuite/skiplist/macos.txt +++ b/testsuite/skiplist/macos.txt @@ -18,6 +18,7 @@ daemon-max-alloc-zero dir-sgid fake-super-acl-xattr link-dest-symlink-enotsup # the ENOTSUP hard-link hook is an LD_PRELOAD, Linux-only +ltfs-daemon ltfs-local open-noatime partial-protected-regular-retry-linux From e82c0d2981887fd2dc7eb00ff0083cb26c3618ff Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 17:35:38 +0300 Subject: [PATCH 14/25] Fixed missing test directory creation on ltfs-daemon test case. --- testsuite/ltfs-daemon_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testsuite/ltfs-daemon_test.py b/testsuite/ltfs-daemon_test.py index 5a835709f..2942fca07 100644 --- a/testsuite/ltfs-daemon_test.py +++ b/testsuite/ltfs-daemon_test.py @@ -17,7 +17,7 @@ import re from rsyncfns import ( - FROMDIR, SCRATCHDIR, TOOLDIR, + FROMDIR, SCRATCHDIR, TODIR, TOOLDIR, build_rsyncd_conf, makepath, run_rsync, start_test_daemon, test_fail, test_skipped, ) @@ -49,6 +49,7 @@ def set_block(path, block): # --- daemon setup ------------------------------------------------------------ +makepath(TODIR) conf = build_rsyncd_conf() url = start_test_daemon(conf, DAEMON_PORT) From 86b06d1c87734c8b5386d7f0430d47382ed35d90 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 17:56:53 +0300 Subject: [PATCH 15/25] Added separate disablement flag for the ltfs awarness functionality in the autotools toolchain. --- configure.ac | 16 ++++++++++++++++ rsync.h | 4 +--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/configure.ac b/configure.ac index 8e4b74505..c238ebbc1 100644 --- a/configure.ac +++ b/configure.ac @@ -1547,6 +1547,22 @@ if test x"$samba_cv_HAVE_POSIX_ACLS" = x"yes"; then esac fi +# check for LTFS tape-ordering support +AC_MSG_CHECKING(whether to support LTFS tape ordering) +AC_ARG_ENABLE(ltfs, + AS_HELP_STRING([--disable-ltfs],[disable LTFS tape-order awareness (--ltfs option)]), + [], [enable_ltfs=yes]) +AH_TEMPLATE([SUPPORT_LTFS], +[Define to 1 to add support for LTFS tape-order-aware transfers]) +if test x"$enable_ltfs" = x"no"; then + AC_MSG_RESULT(no) +elif test x"$enable_xattr_support" = x"no"; then + AC_MSG_RESULT([no (requires xattr support)]) +else + AC_MSG_RESULT(yes) + AC_DEFINE(SUPPORT_LTFS, 1) +fi + if test x"$enable_acl_support" = x"no" || test x"$enable_xattr_support" = x"no" || test x"$enable_iconv" = x"no"; then AC_MSG_CHECKING([whether $CC supports -Wno-unused-parameter]) OLD_CFLAGS="$CFLAGS" diff --git a/rsync.h b/rsync.h index 7b823a45a..870b4dede 100644 --- a/rsync.h +++ b/rsync.h @@ -628,9 +628,7 @@ typedef unsigned int size_t; #define SUPPORT_CRTIMES 1 #endif -#ifdef SUPPORT_XATTRS -#define SUPPORT_LTFS 1 -#endif +/* SUPPORT_LTFS is defined by configure (requires SUPPORT_XATTRS). */ /* Find a variable that is either exactly 32-bits or longer. * If some code depends on 32-bit truncation, it will need to From 9592f164898b123e1ea34184385f469419bc8714 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 18:44:53 +0300 Subject: [PATCH 16/25] ltfs: report LTFS capability in --version and -VV output --- usage.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/usage.c b/usage.c index de586839c..460915102 100644 --- a/usage.c +++ b/usage.c @@ -129,6 +129,11 @@ static void print_info_flags(enum logcode f) #endif "xattrs", +#ifndef SUPPORT_LTFS + "no " +#endif + "ltfs", + #ifdef RSYNC_USE_SECLUDED_ARGS "default " #else From e2e0b83c150fb1875ccce4a45da725a21da3a6f7 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 18:44:59 +0300 Subject: [PATCH 17/25] ltfs: fix test skip guard to check ltfs capability, not xattrs --- testsuite/ltfs-daemon_test.py | 4 ++-- testsuite/ltfs-local_test.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/testsuite/ltfs-daemon_test.py b/testsuite/ltfs-daemon_test.py index 2942fca07..abdad9ec4 100644 --- a/testsuite/ltfs-daemon_test.py +++ b/testsuite/ltfs-daemon_test.py @@ -27,8 +27,8 @@ # --- skip guards (same as ltfs-local_test.py) -------------------------------- vv = run_rsync('-VV', check=True, capture_output=True) -if '"xattrs": true' not in vv.stdout: - test_skipped("rsync built without xattr support") +if '"ltfs": true' not in vv.stdout: + test_skipped("rsync built without LTFS support") if not hasattr(os, 'setxattr'): test_skipped("os.setxattr not available on this platform") diff --git a/testsuite/ltfs-local_test.py b/testsuite/ltfs-local_test.py index 3e3d5fe85..7e4f254ba 100644 --- a/testsuite/ltfs-local_test.py +++ b/testsuite/ltfs-local_test.py @@ -23,10 +23,10 @@ ) -# The feature needs a build with xattr support... +# The feature needs a build with LTFS support... vv = run_rsync('-VV', check=True, capture_output=True) -if '"xattrs": true' not in vv.stdout: - test_skipped("Rsync is configured without xattrs support") +if '"ltfs": true' not in vv.stdout: + test_skipped("Rsync is configured without LTFS support") # ...and a scratch filesystem that lets us set a user.* xattr to stand in for # the tape's ltfs.startblock attribute. From 21de795b8289d181f9757382f854cf24d2128d0f Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 19:20:57 +0300 Subject: [PATCH 18/25] new test: runs rsync_noltfs as the client against an LTFS-capable daemon, verifying both a push and a pull complete correctly with no errors --- testsuite/ltfs-client-compat_test.py | 79 ++++++++++++++++++++++++++++ testsuite/skiplist/cygwin.txt | 1 + testsuite/skiplist/macos.txt | 1 + 3 files changed, 81 insertions(+) create mode 100644 testsuite/ltfs-client-compat_test.py diff --git a/testsuite/ltfs-client-compat_test.py b/testsuite/ltfs-client-compat_test.py new file mode 100644 index 000000000..1e02bbdad --- /dev/null +++ b/testsuite/ltfs-client-compat_test.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# Test that a client built without SUPPORT_LTFS can still perform normal +# transfers to and from an LTFS-capable server. +# +# The LTFS code path on the server is only activated when --ltfs is passed. +# A no-LTFS client connecting without that flag must be fully compatible: +# the server must not allocate LTFS file-list extras, must not attempt to +# send startblock data, and must complete the transfer correctly. +# +# rsync_noltfs is an otherwise-identical rsync binary compiled without +# SUPPORT_XATTRS (which also suppresses SUPPORT_LTFS). It acts as the +# client here; the daemon runs the normal LTFS-capable build. + +import shutil +import subprocess + +from rsyncfns import ( + FROMDIR, TODIR, TOOLDIR, + build_rsyncd_conf, checkit, makepath, rsync_argv, + start_test_daemon, test_fail, test_skipped, +) + +DAEMON_PORT = 12897 + +noltfs_bin = shutil.which('rsync_noltfs', path=str(TOOLDIR)) +if noltfs_bin is None: + test_skipped(f"rsync_noltfs binary not found in TOOLDIR ({TOOLDIR})") + +# --- setup ------------------------------------------------------------------- + +makepath(FROMDIR, TODIR) +files = { + 'alpha.txt': 'content alpha\n', + 'bravo.txt': 'content bravo\n', + 'sub/gamma.txt': 'content gamma\n', +} +for rel, content in files.items(): + f = FROMDIR / rel + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(content) + +# Daemon runs the LTFS-capable binary (default). +conf = build_rsyncd_conf() +url = start_test_daemon(conf, DAEMON_PORT) + +# --- 1. push: no-LTFS client → LTFS-capable server -------------------------- + +res = subprocess.run( + [noltfs_bin, '-r', f'{FROMDIR}/', f'{url}test-to/'], + capture_output=True, text=True, +) +if res.returncode != 0: + test_fail( + f"no-LTFS client push to LTFS-capable server failed " + f"(exit {res.returncode}):\n{res.stderr}" + ) + +checkit(['-r', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) + +# --- 2. pull: no-LTFS client ← LTFS-capable server -------------------------- + +import tempfile +from pathlib import Path +with tempfile.TemporaryDirectory() as tmp: + dst = Path(tmp) / 'pull-dst' + dst.mkdir() + res = subprocess.run( + [noltfs_bin, '-r', f'{url}test-to/', f'{dst}/'], + capture_output=True, text=True, + ) + if res.returncode != 0: + test_fail( + f"no-LTFS client pull from LTFS-capable server failed " + f"(exit {res.returncode}):\n{res.stderr}" + ) + checkit(['-r', f'{TODIR}/', f'{dst}/'], TODIR, dst) + +print("ltfs-client-compat: no-LTFS client completed push and pull " + "against LTFS-capable server without errors") diff --git a/testsuite/skiplist/cygwin.txt b/testsuite/skiplist/cygwin.txt index 52fa1103f..5cc0e6fc9 100644 --- a/testsuite/skiplist/cygwin.txt +++ b/testsuite/skiplist/cygwin.txt @@ -41,6 +41,7 @@ filter-merge-symlink insecure-links-admin-optout link-dest-symlink-enotsup # the ENOTSUP hard-link hook is an LD_PRELOAD, Linux-only log-file-symlink +ltfs-client-compat ltfs-daemon ltfs-local msg-io-timeout-overflow diff --git a/testsuite/skiplist/macos.txt b/testsuite/skiplist/macos.txt index 5dbe019fe..2e771c576 100644 --- a/testsuite/skiplist/macos.txt +++ b/testsuite/skiplist/macos.txt @@ -18,6 +18,7 @@ daemon-max-alloc-zero dir-sgid fake-super-acl-xattr link-dest-symlink-enotsup # the ENOTSUP hard-link hook is an LD_PRELOAD, Linux-only +ltfs-client-compat ltfs-daemon ltfs-local open-noatime From afdc30e7c074e480e6b7b0d326da6a4c3d4d7d88 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 17:44:34 +0100 Subject: [PATCH 19/25] ltfs: skip building rsync_noltfs on platforms without LTFS support CHECK_PROGS unconditionally included rsync_noltfs, so it was built even on MinGW where LTFS is disabled. shutil.which() then found it and the ltfs-server-reject / ltfs-client-compat tests ran instead of skipping. Gate the binary and its intermediate object on the same condition that gates SUPPORT_LTFS in configure.ac: xattr support present and --ltfs not explicitly disabled. --- Makefile.in | 4 ++-- configure.ac | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Makefile.in b/Makefile.in index e74c0aa05..e8a4f680c 100644 --- a/Makefile.in +++ b/Makefile.in @@ -68,13 +68,13 @@ TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/perms CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) t_chmod_secure$(EXEEXT) \ t_rename_secure$(EXEEXT) t_symlink_secure$(EXEEXT) t_secure_relpath$(EXEEXT) t_acl$(EXEEXT) t_hashtable_overflow$(EXEEXT) t_iwildmatch$(EXEEXT) t_clean_fname$(EXEEXT) t_safe_arg$(EXEEXT) wildtest$(EXEEXT) simdtest$(EXEEXT) \ - rsync_noltfs$(EXEEXT) + @LTFS_CHECK_PROGS@ CHECK_SYMLINKS = testsuite/chown-fake_test.py testsuite/devices-fake_test.py \ testsuite/xattrs-hlink_test.py testsuite/exclude-lsh_test.py # Objects for CHECK_PROGS to clean -CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_rename_secure.o t_symlink_secure.o t_secure_relpath.o t_acl.o t_hashtable_overflow.o t_iwildmatch.o t_clean_fname.o t_safe_arg.o trimslash.o wildtest.o options_noltfs.o +CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_rename_secure.o t_symlink_secure.o t_secure_relpath.o t_acl.o t_hashtable_overflow.o t_iwildmatch.o t_clean_fname.o t_safe_arg.o trimslash.o wildtest.o @LTFS_CHECK_OBJS@ # Compile-only feature-shape checks. CHECK_COMPILE_OBJS=syscall-no-at-fdcwd.o diff --git a/configure.ac b/configure.ac index c238ebbc1..0b4a10c05 100644 --- a/configure.ac +++ b/configure.ac @@ -1562,6 +1562,15 @@ else AC_MSG_RESULT(yes) AC_DEFINE(SUPPORT_LTFS, 1) fi +if test x"$enable_ltfs" != x"no" && test x"$enable_xattr_support" != x"no"; then + LTFS_CHECK_PROGS='rsync_noltfs$(EXEEXT)' + LTFS_CHECK_OBJS='options_noltfs.o' +else + LTFS_CHECK_PROGS='' + LTFS_CHECK_OBJS='' +fi +AC_SUBST(LTFS_CHECK_PROGS) +AC_SUBST(LTFS_CHECK_OBJS) if test x"$enable_acl_support" = x"no" || test x"$enable_xattr_support" = x"no" || test x"$enable_iconv" = x"no"; then AC_MSG_CHECKING([whether $CC supports -Wno-unused-parameter]) From 59548aee820349e4772b1cd8b52f4e977f85a341 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 21:41:45 +0100 Subject: [PATCH 20/25] ltfs: LTFS is not a Cygwin target, disable it completely --- configure.ac | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/configure.ac b/configure.ac index 0b4a10c05..5b62524d1 100644 --- a/configure.ac +++ b/configure.ac @@ -1559,12 +1559,27 @@ if test x"$enable_ltfs" = x"no"; then elif test x"$enable_xattr_support" = x"no"; then AC_MSG_RESULT([no (requires xattr support)]) else - AC_MSG_RESULT(yes) - AC_DEFINE(SUPPORT_LTFS, 1) + case $host_os in + *cygwin*) + AC_MSG_RESULT([no (Cygwin)]) + ;; + *) + AC_MSG_RESULT(yes) + AC_DEFINE(SUPPORT_LTFS, 1) + ;; + esac fi if test x"$enable_ltfs" != x"no" && test x"$enable_xattr_support" != x"no"; then - LTFS_CHECK_PROGS='rsync_noltfs$(EXEEXT)' - LTFS_CHECK_OBJS='options_noltfs.o' + case $host_os in + *cygwin*) + LTFS_CHECK_PROGS='' + LTFS_CHECK_OBJS='' + ;; + *) + LTFS_CHECK_PROGS='rsync_noltfs$(EXEEXT)' + LTFS_CHECK_OBJS='options_noltfs.o' + ;; + esac else LTFS_CHECK_PROGS='' LTFS_CHECK_OBJS='' From a26a0ee0bfc589414ce7bec98b6ca8433ed6f16a Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 21:05:17 +0100 Subject: [PATCH 21/25] Added ./autogen.sh (as is available in some other projects) for forcing regenerating configure files after make distclean --- autogen.sh | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 autogen.sh diff --git a/autogen.sh b/autogen.sh new file mode 100644 index 000000000..f13aa6edc --- /dev/null +++ b/autogen.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Regenerate the autoconf build files from configure.ac. +# Run this after "make distclean" or a fresh clone before ./configure. +set -e +aclocal -I m4 +autoconf -o configure.sh +autoheader && touch config.h.in From 2589507afefaf6f893ad932790e438581b966c71 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sat, 13 Jun 2026 23:30:57 +0300 Subject: [PATCH 22/25] Update Makefile.in referencing the autogen.sh script in Makefile.in --- Makefile.in | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Makefile.in b/Makefile.in index e8a4f680c..27c44b749 100644 --- a/Makefile.in +++ b/Makefile.in @@ -281,8 +281,7 @@ aclocal.m4: $(srcdir)/m4/*.m4 configure.sh config.h.in: configure.ac aclocal.m4 @if test -f configure.sh; then cp -p configure.sh configure.sh.old; else touch configure.sh.old; fi @if test -f config.h.in; then cp -p config.h.in config.h.in.old; else touch config.h.in.old; fi - autoconf -o configure.sh - autoheader && touch config.h.in + $(srcdir)/autogen.sh @if diff configure.sh configure.sh.old >/dev/null 2>&1; then \ echo "configure.sh is unchanged."; \ rm configure.sh.old; \ From ea975cd9679125e66e6084c077a4267d435f4fcc Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sun, 14 Jun 2026 00:06:11 +0300 Subject: [PATCH 23/25] Matching the test invokes to rest of the project tests --- Makefile.in | 12 ++++++------ configure.ac | 4 ++++ testsuite/ltfs-client-compat_test.py | 7 ++----- testsuite/ltfs-server-reject_test.py | 7 ++----- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/Makefile.in b/Makefile.in index 27c44b749..570929913 100644 --- a/Makefile.in +++ b/Makefile.in @@ -450,15 +450,15 @@ check-progs: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) .PHONY: check check: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) - "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) + "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) @LTFS_RUNTESTS_ARGS@ .PHONY: check29 check29: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) - "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=29 + "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=29 @LTFS_RUNTESTS_ARGS@ .PHONY: check30 check30: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) - "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=30 + "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=30 @LTFS_RUNTESTS_ARGS@ # Whole-suite gcov coverage report (HTML, with branch + decision coverage). # Requires a build configured with --enable-coverage and the `gcovr` tool @@ -486,7 +486,7 @@ coverage: all $(CHECK_PROGS) $(CHECK_SYMLINKS) chmod a+rwx "$$d"; \ setfacl -m 'd:u::rwx,d:g::rwx,d:o::rwx' "$$d" 2>/dev/null || true; \ done - @rc=0; "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $(COVERAGE_RUNFLAGS) || rc=$$?; \ + @rc=0; "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $(COVERAGE_RUNFLAGS) @LTFS_RUNTESTS_ARGS@ || rc=$$?; \ rm -rf $(COVERAGE_DIR) && mkdir -p $(COVERAGE_DIR); \ gcovr --root $(srcdir) $(COVERAGE_EXCLUDE) --decisions --print-summary \ --gcov-ignore-parse-errors=negative_hits.warn_once_per_file \ @@ -526,7 +526,7 @@ coverage-all: all $(CHECK_PROGS) $(CHECK_SYMLINKS) @rc=0; \ for cfg in '' '--protocol=30' '--protocol=29' '--use-tcp'; do \ echo "===== coverage-all: runtests.py $$cfg ====="; \ - "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $$cfg || rc=$$?; \ + "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $$cfg @LTFS_RUNTESTS_ARGS@ || rc=$$?; \ done; \ rm -rf coverage-all && mkdir -p coverage-all; \ gcovr --root $(srcdir) $(COVERAGE_EXCLUDE) --decisions --print-summary \ @@ -581,7 +581,7 @@ testsuite/exclude-lsh_test.py: .PHONY: installcheck installcheck: $(CHECK_PROGS) $(CHECK_SYMLINKS) - "$(srcdir)/runtests.py" --rsync-bin="$(bindir)/rsync$(EXEEXT)" --srcdir="$(srcdir)" --tooldir="`pwd`" -j $(CHECK_J) + "$(srcdir)/runtests.py" --rsync-bin="$(bindir)/rsync$(EXEEXT)" --srcdir="$(srcdir)" --tooldir="`pwd`" -j $(CHECK_J) @LTFS_RUNTESTS_ARGS@ # TODO: Add 'dist' target; need to know which files will be included diff --git a/configure.ac b/configure.ac index 5b62524d1..db1dc1aee 100644 --- a/configure.ac +++ b/configure.ac @@ -1574,18 +1574,22 @@ if test x"$enable_ltfs" != x"no" && test x"$enable_xattr_support" != x"no"; then *cygwin*) LTFS_CHECK_PROGS='' LTFS_CHECK_OBJS='' + LTFS_RUNTESTS_ARGS='--exclude=ltfs-server-reject,ltfs-client-compat' ;; *) LTFS_CHECK_PROGS='rsync_noltfs$(EXEEXT)' LTFS_CHECK_OBJS='options_noltfs.o' + LTFS_RUNTESTS_ARGS='' ;; esac else LTFS_CHECK_PROGS='' LTFS_CHECK_OBJS='' + LTFS_RUNTESTS_ARGS='--exclude=ltfs-server-reject,ltfs-client-compat' fi AC_SUBST(LTFS_CHECK_PROGS) AC_SUBST(LTFS_CHECK_OBJS) +AC_SUBST(LTFS_RUNTESTS_ARGS) if test x"$enable_acl_support" = x"no" || test x"$enable_xattr_support" = x"no" || test x"$enable_iconv" = x"no"; then AC_MSG_CHECKING([whether $CC supports -Wno-unused-parameter]) diff --git a/testsuite/ltfs-client-compat_test.py b/testsuite/ltfs-client-compat_test.py index 1e02bbdad..6a91780d9 100644 --- a/testsuite/ltfs-client-compat_test.py +++ b/testsuite/ltfs-client-compat_test.py @@ -11,20 +11,17 @@ # SUPPORT_XATTRS (which also suppresses SUPPORT_LTFS). It acts as the # client here; the daemon runs the normal LTFS-capable build. -import shutil import subprocess from rsyncfns import ( FROMDIR, TODIR, TOOLDIR, build_rsyncd_conf, checkit, makepath, rsync_argv, - start_test_daemon, test_fail, test_skipped, + start_test_daemon, test_fail, ) DAEMON_PORT = 12897 -noltfs_bin = shutil.which('rsync_noltfs', path=str(TOOLDIR)) -if noltfs_bin is None: - test_skipped(f"rsync_noltfs binary not found in TOOLDIR ({TOOLDIR})") +noltfs_bin = str(TOOLDIR / 'rsync_noltfs') # --- setup ------------------------------------------------------------------- diff --git a/testsuite/ltfs-server-reject_test.py b/testsuite/ltfs-server-reject_test.py index 15a9b7448..aa7ed8001 100644 --- a/testsuite/ltfs-server-reject_test.py +++ b/testsuite/ltfs-server-reject_test.py @@ -8,20 +8,17 @@ # local pipe (RSYNC_CONNECT_PROG) so no TCP socket or real LTFS volume is # needed, and the test runs on all platforms regardless of xattr support. -import shutil import subprocess from rsyncfns import ( FROMDIR, TODIR, TOOLDIR, build_rsyncd_conf, makepath, rsync_argv, - start_test_daemon, test_fail, test_skipped, + start_test_daemon, test_fail, ) DAEMON_PORT = 12896 -noltfs_bin = shutil.which('rsync_noltfs', path=str(TOOLDIR)) -if noltfs_bin is None: - test_skipped(f"rsync_noltfs binary not found in TOOLDIR ({TOOLDIR})") +noltfs_bin = str(TOOLDIR / 'rsync_noltfs') makepath(FROMDIR, TODIR) (FROMDIR / 'probe.txt').write_text('hello from the ltfs-server-reject test\n') From df0b887b0bd55d10023bbc65843e5049d877cca2 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Sun, 14 Jun 2026 00:58:16 +0300 Subject: [PATCH 24/25] Made autogen.sh executable by default --- autogen.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 autogen.sh diff --git a/autogen.sh b/autogen.sh old mode 100644 new mode 100755 From a77bb00adaf0e5fbb9548f4205caf263b70a989b Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Wed, 12 Aug 2026 22:53:36 +0300 Subject: [PATCH 25/25] ltfs: make ltfs-client-compat deterministic and move its daemon port The test transferred with -r but compared tls listings that include mtimes. Without -t the destination mtimes are set to the transfer time rather than copied from the source, so the listings only matched when the whole test happened to run inside a single wall-clock second. It failed intermittently otherwise (~25% of parallel runs here). The other ltfs tests pass --ltfs, which implies -t, so this was the only one affected. Preserve times on all four transfers instead. Also move DAEMON_PORT off 12897, which daemon-path-root-read_test.py already uses, to the unused 12900. --- testsuite/ltfs-client-compat_test.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/testsuite/ltfs-client-compat_test.py b/testsuite/ltfs-client-compat_test.py index 6a91780d9..1821f6908 100644 --- a/testsuite/ltfs-client-compat_test.py +++ b/testsuite/ltfs-client-compat_test.py @@ -19,7 +19,7 @@ start_test_daemon, test_fail, ) -DAEMON_PORT = 12897 +DAEMON_PORT = 12900 noltfs_bin = str(TOOLDIR / 'rsync_noltfs') @@ -43,7 +43,7 @@ # --- 1. push: no-LTFS client → LTFS-capable server -------------------------- res = subprocess.run( - [noltfs_bin, '-r', f'{FROMDIR}/', f'{url}test-to/'], + [noltfs_bin, '-rt', f'{FROMDIR}/', f'{url}test-to/'], capture_output=True, text=True, ) if res.returncode != 0: @@ -52,7 +52,7 @@ f"(exit {res.returncode}):\n{res.stderr}" ) -checkit(['-r', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) +checkit(['-rt', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) # --- 2. pull: no-LTFS client ← LTFS-capable server -------------------------- @@ -62,7 +62,7 @@ dst = Path(tmp) / 'pull-dst' dst.mkdir() res = subprocess.run( - [noltfs_bin, '-r', f'{url}test-to/', f'{dst}/'], + [noltfs_bin, '-rt', f'{url}test-to/', f'{dst}/'], capture_output=True, text=True, ) if res.returncode != 0: @@ -70,7 +70,7 @@ f"no-LTFS client pull from LTFS-capable server failed " f"(exit {res.returncode}):\n{res.stderr}" ) - checkit(['-r', f'{TODIR}/', f'{dst}/'], TODIR, dst) + checkit(['-rt', f'{TODIR}/', f'{dst}/'], TODIR, dst) print("ltfs-client-compat: no-LTFS client completed push and pull " "against LTFS-capable server without errors")