From dc122ed263b9348c19f06a60e31c95cc11df2c87 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 00:04:33 +0300 Subject: [PATCH 1/8] 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 5216fb2f7..ca93e9032 100644 --- a/Makefile.in +++ b/Makefile.in @@ -44,7 +44,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=flist.o rsync.o generator.o receiver.o cleanup.o sender.o exclude.o \ - util1.o util2.o main.o checksum.o match.o syscall.o android.o log.o backup.o delete.o + util1.o util2.o main.o checksum.o match.o syscall.o android.o log.o backup.o delete.o ltfs.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 OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ diff --git a/compat.c b/compat.c index d1ca31614..f2353e1d3 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 */ @@ -581,6 +582,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 346540fbf..01c6b750b 100644 --- a/flist.c +++ b/flist.c @@ -57,6 +57,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; @@ -602,6 +603,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)) @@ -697,6 +700,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; @@ -873,6 +877,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 @@ -1097,6 +1103,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; @@ -1518,6 +1526,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 3c2d23526..3a364ec9a 100644 --- a/options.c +++ b/options.c @@ -112,6 +112,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 @@ -623,6 +624,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 }, @@ -1028,6 +1031,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++) { @@ -2397,6 +2405,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, @@ -2765,6 +2790,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 cdc2d2c0d..7b7491fc2 100644 --- a/rsync.h +++ b/rsync.h @@ -838,6 +838,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; @@ -902,6 +903,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 a95ad45482dee35534afea12ba7b778b241a6622 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 00:05:28 +0300 Subject: [PATCH 2/8] 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 09e276d1f..228a0fad5 100644 --- a/generator.c +++ b/generator.c @@ -32,6 +32,7 @@ extern int am_root; 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; @@ -2243,6 +2244,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; @@ -2326,8 +2328,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; @@ -2335,7 +2344,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); @@ -2354,6 +2363,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 3646093b96e7d363d971f855625574926328bb9f Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 00:11:36 +0300 Subject: [PATCH 3/8] 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 fdf0b2e95..cea7a091f 100644 --- a/rsync.1.md +++ b/rsync.1.md @@ -428,6 +428,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 dirs with --relative --backup, -b make backups (see --suffix & --backup-dir) @@ -915,6 +916,37 @@ 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 b74efcb0d9d7e78ab84acde6018ae8a89943dd76 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 00:11:36 +0300 Subject: [PATCH 4/8] 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 f83551503519d47484a5409c11ccaac1c02ec1c0 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 11:38:48 +0300 Subject: [PATCH 5/8] 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 3a364ec9a..2d7278f4d 100644 --- a/options.c +++ b/options.c @@ -595,7 +595,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[] = { @@ -624,7 +624,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 }, @@ -1577,6 +1577,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; @@ -2420,6 +2428,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 1965c7e00f4f82dbd8315f34c0544e5276c0e2ff Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 11:38:48 +0300 Subject: [PATCH 6/8] 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 cea7a091f..109e919ba 100644 --- a/rsync.1.md +++ b/rsync.1.md @@ -938,9 +938,14 @@ 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 30fe74d0943123dcf707d8c6e86723ce9948a05b Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 11:38:48 +0300 Subject: [PATCH 7/8] 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 28aa2f54797f079074ca62c8afbced43c73a0b50 Mon Sep 17 00:00:00 2001 From: Hugo Hurskainen Date: Fri, 12 Jun 2026 14:35:57 +0100 Subject: [PATCH 8/8] 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')