From 7e2b0fdb6b646f809d66dbd8c65f5f3d88eaf9ae Mon Sep 17 00:00:00 2001 From: Matej Kenda Date: Wed, 10 Jun 2026 22:23:08 +0200 Subject: [PATCH 1/3] fix: pass file permissions to open(2) in arch_open on POSIX The POSIX arch_open macro passed the Windows-style share flag as the mode argument of open(2) and ignored the actual permission argument. Files created by the file tape backend got mode 0200 (write-only), so a non-root user could not reopen records it had just written; mounting a freshly formatted file-backend volume failed with EDEV_RW_PERM. Running as root masked the problem. --- src/libltfs/arch/ltfs_arch_ops.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libltfs/arch/ltfs_arch_ops.h b/src/libltfs/arch/ltfs_arch_ops.h index 242d6dc8..7c4684ad 100644 --- a/src/libltfs/arch/ltfs_arch_ops.h +++ b/src/libltfs/arch/ltfs_arch_ops.h @@ -151,7 +151,10 @@ extern "C" { #define arch_sscanf sscanf - #define arch_open( descriptor_ptr, filename_ptr, open_flg, share_flg, unused) do{ *descriptor_ptr = open(filename_ptr, open_flg, share_flg); }while(0) + /* Share flags are a Windows concept; on POSIX the permission argument + * is the mode passed to open(2). Passing the share flag as the mode + * created write-only (0200) files, unreadable for non-root users. */ + #define arch_open( descriptor_ptr, filename_ptr, open_flg, share_flg, perm) do{ *descriptor_ptr = open(filename_ptr, open_flg, perm); }while(0) #define arch_fopen(file, mode, file_ptr) do {file_ptr = fopen(file, mode);}while(0) From 406878c4faa86789c2f6aae6d544524ce68b6322 Mon Sep 17 00:00:00 2001 From: Matej Kenda Date: Fri, 12 Jun 2026 12:58:16 +0200 Subject: [PATCH 2/3] Add an integration test suite using the file tape backend Shell-based tests driven by automake (make check) covering mount and unmount, data roundtrips, truncate, rename, directory operations, xattrs, symlinks, large directories, and a tar roundtrip, plus a regression test for the unified scheduler data loss (#591): single multi-block writes of random data, content-compared while mounted and after a remount. Tests mount a file-backend volume from the build tree, so no installation or tape hardware is needed; they are skipped on hosts without /dev/fuse. tests/run-in-docker.sh builds and runs the suite in an Ubuntu container for development on non-Linux hosts. A small C helper drives renameat2() with RENAME_NOREPLACE and RENAME_EXCHANGE and ftruncate() on an open descriptor; renameat2 is Linux-only, so the helper reports the commands as unsupported elsewhere. Inode numbers must survive a remount, which verifies that the LTFS index UIDs are passed through (use_ino) on both FUSE versions. Three tests cover the FUSE request size, -o direct_io, and readdirplus. Their assertions are FUSE-version aware: on libfuse 2 the request-size test expects the big_writes limit and the readdirplus prefill is not asserted (it needs libfuse >= 3.17); the strict expectations activate with the FUSE 3 port. All pass on both APIs. The harness locates build artifacts in both the autotools and CMake layouts, so the same scripts run under make check and ctest, and the test list is computed from tests/t/ so other branches can add scripts without editing the registration. --- .gitignore | 7 + Makefile.am | 2 +- configure.ac | 6 + tests/Makefile.am | 31 +++++ tests/docker/Dockerfile | 16 +++ tests/docker/icu-config | 12 ++ tests/helpers/fsops_helper.c | 64 +++++++++ tests/lib/harness.sh | 195 +++++++++++++++++++++++++++ tests/run-in-docker.sh | 30 +++++ tests/t/00-mount-umount.sh | 13 ++ tests/t/01-io-roundtrip.sh | 34 +++++ tests/t/02-truncate.sh | 24 ++++ tests/t/03-rename.sh | 36 +++++ tests/t/04-dirs-unlink.sh | 29 ++++ tests/t/05-xattr.sh | 29 ++++ tests/t/06-symlink.sh | 21 +++ tests/t/07-large-dir.sh | 28 ++++ tests/t/08-tar-roundtrip.sh | 28 ++++ tests/t/09-rename-flags-ftruncate.sh | 46 +++++++ tests/t/10-request-size.sh | 40 ++++++ tests/t/11-direct-io.sh | 37 +++++ tests/t/12-readdirplus.sh | 55 ++++++++ tests/t/13-large-write-integrity.sh | 35 +++++ 23 files changed, 817 insertions(+), 1 deletion(-) create mode 100644 tests/Makefile.am create mode 100644 tests/docker/Dockerfile create mode 100644 tests/docker/icu-config create mode 100644 tests/helpers/fsops_helper.c create mode 100644 tests/lib/harness.sh create mode 100755 tests/run-in-docker.sh create mode 100755 tests/t/00-mount-umount.sh create mode 100755 tests/t/01-io-roundtrip.sh create mode 100755 tests/t/02-truncate.sh create mode 100755 tests/t/03-rename.sh create mode 100755 tests/t/04-dirs-unlink.sh create mode 100755 tests/t/05-xattr.sh create mode 100755 tests/t/06-symlink.sh create mode 100755 tests/t/07-large-dir.sh create mode 100755 tests/t/08-tar-roundtrip.sh create mode 100755 tests/t/09-rename-flags-ftruncate.sh create mode 100755 tests/t/10-request-size.sh create mode 100755 tests/t/11-direct-io.sh create mode 100755 tests/t/12-readdirplus.sh create mode 100755 tests/t/13-large-write-integrity.sh diff --git a/.gitignore b/.gitignore index 0711b11e..61537161 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,10 @@ GTAGS TAGS tags *~ +# Files generated by make check +tests/t/*.log +tests/t/*.trs +tests/test-suite.log +build.log +configure.log +tests/helpers/fsops_helper diff --git a/Makefile.am b/Makefile.am index 31ff904c..fe8f55a4 100644 --- a/Makefile.am +++ b/Makefile.am @@ -37,7 +37,7 @@ ACLOCAL_AMFLAGS = -I m4 nobase_pkginclude_HEADERS = config.h -SUBDIRS = messages src conf init.d man +SUBDIRS = messages src conf init.d man tests install-data-local: ltfs.pc if [ ! -d $(DESTDIR)$(libdir)/pkgconfig ]; then \ diff --git a/configure.ac b/configure.ac index f28d2b28..ef5cd3d9 100644 --- a/configure.ac +++ b/configure.ac @@ -601,6 +601,11 @@ dnl dnl Output files dnl AC_CONFIG_HEADERS([config.h]) +dnl Compute the integration test list at configure time so branches can +dnl add tests/t/*.sh scripts without editing the build files. +TESTS_LIST=`cd "$srcdir/tests" 2>/dev/null && ls t/*.sh 2>/dev/null | sort | tr '\n' ' '` +AC_SUBST([TESTS_LIST]) + AC_CONFIG_FILES([ Makefile messages/Makefile @@ -619,6 +624,7 @@ AC_CONFIG_FILES([ src/iosched/Makefile src/kmi/Makefile src/utils/Makefile + tests/Makefile ltfs.pc:ltfs.pc.in ]) diff --git a/tests/Makefile.am b/tests/Makefile.am new file mode 100644 index 00000000..9015f115 --- /dev/null +++ b/tests/Makefile.am @@ -0,0 +1,31 @@ +# +# Integration tests for LTFS using the file tape backend. +# +# Run with: make check +# The tests need a Linux host with /dev/fuse; elsewhere they are skipped. +# On macOS use tests/run-in-docker.sh to run them in a Linux container. +# + +TEST_EXTENSIONS = .sh +SH_LOG_COMPILER = $(SHELL) + +AM_TESTS_ENVIRONMENT = \ + top_builddir='$(abs_top_builddir)' top_srcdir='$(abs_top_srcdir)'; \ + export top_builddir top_srcdir; + +check_PROGRAMS = helpers/fsops_helper +helpers_fsops_helper_SOURCES = helpers/fsops_helper.c + +# The test list is computed at configure time (TESTS_LIST in configure.ac) +# so feature branches can ship additional t/*.sh scripts without editing +# the build files; ctest registers them the same way. GNU make functions +# cannot be used here: automake runs with -Wall -Werror, which rejects +# them as non-POSIX. +TESTS = $(TESTS_LIST) + +EXTRA_DIST = \ + lib/harness.sh \ + $(TESTS) \ + docker/Dockerfile \ + docker/icu-config \ + run-in-docker.sh diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile new file mode 100644 index 00000000..3e2b774c --- /dev/null +++ b/tests/docker/Dockerfile @@ -0,0 +1,16 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get -q update && apt-get -q -y install --no-install-recommends \ + build-essential automake autoconf libtool pkg-config \ + icu-devtools libicu-dev libxml2-dev uuid-dev libsnmp-dev \ + libfuse-dev fuse3 libfuse3-dev \ + attr file procps util-linux tar \ + && rm -rf /var/lib/apt/lists/* + +# configure.ac probes icu-config, which modern ICU no longer ships. +COPY icu-config /usr/local/bin/icu-config +RUN chmod +x /usr/local/bin/icu-config + +WORKDIR /ltfs diff --git a/tests/docker/icu-config b/tests/docker/icu-config new file mode 100644 index 00000000..c6af7cb8 --- /dev/null +++ b/tests/docker/icu-config @@ -0,0 +1,12 @@ +#!/bin/sh +# Minimal icu-config replacement for distributions that no longer ship it. +case "$1" in + --cppflags) + pkg-config --cflags icu-uc ;; + --ldflags) + pkg-config --libs icu-uc icu-io ;; + --version) + pkg-config --modversion icu-uc ;; + *) + echo '' ;; +esac diff --git a/tests/helpers/fsops_helper.c b/tests/helpers/fsops_helper.c new file mode 100644 index 00000000..7bb6e4c1 --- /dev/null +++ b/tests/helpers/fsops_helper.c @@ -0,0 +1,64 @@ +/* Test helper exercising syscalls that shell utilities do not reach + * directly: renameat2() flags and ftruncate() on an open descriptor. + * + * Usage: + * fsops_helper noreplace renameat2 with RENAME_NOREPLACE + * fsops_helper exchange renameat2 with RENAME_EXCHANGE + * fsops_helper ftruncate ftruncate an open fd, print new size + * + * Exit codes: 0 = success, 2 = syscall failed (errno printed), 3 = usage. + */ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) +{ + if (argc != 4) { + fprintf(stderr, "usage: %s noreplace|exchange|ftruncate \n", + argv[0]); + return 3; + } + + if (!strcmp(argv[1], "noreplace") || !strcmp(argv[1], "exchange")) { +#ifdef __linux__ + unsigned int flags = !strcmp(argv[1], "noreplace") ? + RENAME_NOREPLACE : RENAME_EXCHANGE; + + if (renameat2(AT_FDCWD, argv[2], AT_FDCWD, argv[3], flags) < 0) { + printf("%s\n", strerror(errno)); + return 2; + } + return 0; +#else + /* The integration tests only run on Linux; keep the helper + * compiling on the other platforms. */ + fprintf(stderr, "rename flags are not supported on this platform\n"); + return 3; +#endif + } + + if (!strcmp(argv[1], "ftruncate")) { + struct stat st; + off_t len = strtoll(argv[3], NULL, 10); + int fd = open(argv[2], O_RDWR); + + if (fd < 0 || ftruncate(fd, len) < 0 || fstat(fd, &st) < 0) { + printf("%s\n", strerror(errno)); + return 2; + } + printf("%lld\n", (long long)st.st_size); + close(fd); + return 0; + } + + fprintf(stderr, "unknown command: %s\n", argv[1]); + return 3; +} diff --git a/tests/lib/harness.sh b/tests/lib/harness.sh new file mode 100644 index 00000000..1b7dab8e --- /dev/null +++ b/tests/lib/harness.sh @@ -0,0 +1,195 @@ +# Common helpers for LTFS integration tests. +# +# Tests run against the build tree (no installation needed) using the +# file tape backend, which emulates a tape drive in a plain directory. +# +# A test script sources this file, calls ltfs_setup, performs its checks +# under $MNT, then calls ltfs_finish (unmount + ltfsck). Cleanup of +# mounts and temporary files is handled by an EXIT trap. + +set -eu + +SKIP=77 + +: "${top_builddir:?top_builddir must be set (run via make check)}" +: "${top_srcdir:?top_srcdir must be set (run via make check)}" + +LTFS_BIN="$top_builddir/src/ltfs" +MKLTFS_BIN="$top_builddir/src/utils/mkltfs" +LTFSCK_BIN="$top_builddir/src/utils/ltfsck" + +# Extra mount options for the ltfs invocation; tests may override. +LTFS_MOUNT_OPTS="${LTFS_MOUNT_OPTS:-}" + +WORK= +LTFS_PID= +MNT= +TAPE= + +skip() { + echo "SKIP: $*" + exit "$SKIP" +} + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +ltfs_check_env() { + [ "$(uname -s)" = "Linux" ] || skip "FUSE integration tests only run on Linux" + [ -e /dev/fuse ] || skip "/dev/fuse is not available" + command -v fusermount3 >/dev/null 2>&1 || command -v fusermount >/dev/null 2>&1 \ + || [ "$(id -u)" = "0" ] || skip "fusermount is not available" + [ -x "$LTFS_BIN" ] || fail "ltfs binary not found at $LTFS_BIN" + [ -x "$MKLTFS_BIN" ] || fail "mkltfs binary not found at $MKLTFS_BIN" + [ -x "$LTFSCK_BIN" ] || fail "ltfsck binary not found at $LTFSCK_BIN" +} + +_fusermount() { + if command -v fusermount3 >/dev/null 2>&1; then + fusermount3 "$@" + elif command -v fusermount >/dev/null 2>&1; then + fusermount "$@" + else + # root can unmount directly + shift # drop -u + umount "$@" + fi +} + +ltfs_cleanup() { + status=$? + set +e + if [ -n "$MNT" ] && mountpoint -q "$MNT" 2>/dev/null; then + _fusermount -u "$MNT" 2>/dev/null || umount "$MNT" 2>/dev/null + fi + if [ -n "$LTFS_PID" ] && kill -0 "$LTFS_PID" 2>/dev/null; then + # Give the daemon time to flush the index after unmount. + for _ in $(seq 50); do + kill -0 "$LTFS_PID" 2>/dev/null || break + sleep 0.2 + done + kill "$LTFS_PID" 2>/dev/null + fi + [ -n "$WORK" ] && rm -rf "$WORK" + exit "$status" +} + +# Locate a built plugin .so, accepting both the autotools (libtool .libs/) +# and the CMake (plain subdir) layouts. +_find_plugin() { + subdir=$1 + base=$2 + for cand in \ + "$top_builddir/src/$subdir/.libs/$base.so" \ + "$top_builddir/src/$subdir/$base.so"; do + if [ -f "$cand" ]; then + echo "$cand" + return 0 + fi + done + fail "plugin $base.so not found under $top_builddir/src/$subdir" +} + +# Generate an ltfs.conf pointing at the plugins in the build tree. +_write_config() { + cat >"$WORK/ltfs.conf" <"$WORK/mkltfs.log" 2>&1 || { + cat "$WORK/mkltfs.log" >&2 + fail "mkltfs failed" + } + + # Run in the foreground so we keep the pid and the log. + # shellcheck disable=SC2086 + "$LTFS_BIN" "$MNT" -o config_file="$WORK/ltfs.conf" -o tape_backend=file \ + -o devname="$TAPE" $LTFS_MOUNT_OPTS -f >"$WORK/ltfs.log" 2>&1 & + LTFS_PID=$! + + for _ in $(seq 150); do + mountpoint -q "$MNT" && return 0 + kill -0 "$LTFS_PID" 2>/dev/null || break + sleep 0.2 + done + cat "$WORK/ltfs.log" >&2 + fail "ltfs did not mount within timeout" +} + +# ltfs_umount: unmount and wait for the daemon to flush and exit. +ltfs_umount() { + _fusermount -u "$MNT" + for _ in $(seq 150); do + kill -0 "$LTFS_PID" 2>/dev/null || { LTFS_PID=; return 0; } + sleep 0.2 + done + cat "$WORK/ltfs.log" >&2 + fail "ltfs daemon did not exit after unmount" +} + +# ltfs_fsck: verify the volume is consistent. +# Exit codes follow fsck conventions: 0 = clean, 1 = corrected/treat as +# success (e.g. MAM coherency update); anything else is a failure. +ltfs_fsck() { + rc=0 + "$LTFSCK_BIN" -i "$WORK/ltfs.conf" -e file "$TAPE" >"$WORK/ltfsck.log" 2>&1 || rc=$? + if [ "$rc" -gt 1 ]; then + cat "$WORK/ltfsck.log" >&2 + fail "ltfsck reported errors (exit $rc)" + fi + grep -q "Volume is consistent" "$WORK/ltfsck.log" || { + cat "$WORK/ltfsck.log" >&2 + fail "ltfsck did not report a consistent volume" + } +} + +# ltfs_finish: standard end of test (unmount + consistency check). +ltfs_finish() { + ltfs_umount + ltfs_fsck +} + +# ltfs_is_fuse3: true when the ltfs binary is linked against libfuse 3. +ltfs_is_fuse3() { + { ldd "$LTFS_BIN" 2>/dev/null || ldd "$top_builddir/src/.libs/ltfs" 2>/dev/null; } \ + | grep -q libfuse3 +} + +# ltfs_remount: unmount and mount again (e.g. to verify persistence). +ltfs_remount() { + ltfs_umount + # shellcheck disable=SC2086 + "$LTFS_BIN" "$MNT" -o config_file="$WORK/ltfs.conf" -o tape_backend=file \ + -o devname="$TAPE" $LTFS_MOUNT_OPTS -f >"$WORK/ltfs-remount.log" 2>&1 & + LTFS_PID=$! + for _ in $(seq 150); do + mountpoint -q "$MNT" && return 0 + kill -0 "$LTFS_PID" 2>/dev/null || break + sleep 0.2 + done + cat "$WORK/ltfs-remount.log" >&2 + fail "ltfs did not remount within timeout" +} diff --git a/tests/run-in-docker.sh b/tests/run-in-docker.sh new file mode 100755 index 00000000..fe933f55 --- /dev/null +++ b/tests/run-in-docker.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Build LTFS and run the test suite inside a Linux container. +# FUSE mounts require /dev/fuse and CAP_SYS_ADMIN in the container. +# +# Usage: tests/run-in-docker.sh [configure-options...] +# e.g. tests/run-in-docker.sh --with-fuse2 +# Environment: +# LTFS_DOCKER_SHELL=1 drop into an interactive shell instead of building + +set -eu + +top_srcdir=$(cd "$(dirname "$0")/.." && pwd) +image=ltfs-test + +docker build -q -t "$image" "$top_srcdir/tests/docker" >/dev/null + +run_flags="--rm --device /dev/fuse --cap-add SYS_ADMIN --security-opt apparmor:unconfined" + +if [ "${LTFS_DOCKER_SHELL:-0}" = "1" ]; then + # shellcheck disable=SC2086 + exec docker run $run_flags -it -v "$top_srcdir:/ltfs" "$image" bash +fi + +# shellcheck disable=SC2086 +exec docker run $run_flags -v "$top_srcdir:/ltfs" "$image" sh -ec " + ./autogen.sh + ./configure --enable-icu-6x $* + make -j\$(nproc) + make check VERBOSE=1 +" diff --git a/tests/t/00-mount-umount.sh b/tests/t/00-mount-umount.sh new file mode 100755 index 00000000..072c18da --- /dev/null +++ b/tests/t/00-mount-umount.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Format, mount, unmount, fsck: the volume must be consistent and empty. +. "${top_srcdir}/tests/lib/harness.sh" + +ltfs_setup + +mountpoint -q "$MNT" || fail "mountpoint not active" +[ -z "$(ls -A "$MNT")" ] || fail "freshly formatted volume is not empty" + +df -P "$MNT" | grep -q "$MNT" || fail "statfs does not report the volume" + +ltfs_finish +echo "PASS" diff --git a/tests/t/01-io-roundtrip.sh b/tests/t/01-io-roundtrip.sh new file mode 100755 index 00000000..61531ee9 --- /dev/null +++ b/tests/t/01-io-roundtrip.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Write files of various sizes, verify checksums before and after remount. +. "${top_srcdir}/tests/lib/harness.sh" + +ltfs_setup + +mkdir "$MNT/data" + +# Small, block-sized, and multi-megabyte files +dd if=/dev/urandom of="$WORK/small" bs=1234 count=1 status=none +dd if=/dev/urandom of="$WORK/medium" bs=512K count=1 status=none +dd if=/dev/urandom of="$WORK/large" bs=1M count=8 status=none + +for f in small medium large; do + cp "$WORK/$f" "$MNT/data/$f" +done + +( cd "$WORK" && sha256sum small medium large >"$WORK/sums" ) +( cd "$MNT/data" && sha256sum -c "$WORK/sums" >/dev/null ) \ + || fail "checksum mismatch while mounted" + +# Overwrite in place and append +printf 'rewrite' | dd of="$MNT/data/small" bs=1 seek=10 conv=notrunc status=none +cat "$WORK/small" >>"$MNT/data/medium" + +ltfs_remount + +( cd "$MNT/data" && sha256sum large ) | (cd "$WORK" && sha256sum -c >/dev/null) \ + || fail "checksum mismatch after remount" +[ "$(stat -c %s "$MNT/data/medium")" -eq $((524288 + 1234)) ] \ + || fail "appended size wrong after remount" + +ltfs_finish +echo "PASS" diff --git a/tests/t/02-truncate.sh b/tests/t/02-truncate.sh new file mode 100755 index 00000000..9055eb05 --- /dev/null +++ b/tests/t/02-truncate.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Truncate by path (shrink and extend) and verify content and size. +. "${top_srcdir}/tests/lib/harness.sh" + +ltfs_setup + +dd if=/dev/urandom of="$MNT/file" bs=1M count=2 status=none + +truncate -s 1M "$MNT/file" +[ "$(stat -c %s "$MNT/file")" -eq 1048576 ] || fail "shrink truncate size" + +truncate -s 3M "$MNT/file" +[ "$(stat -c %s "$MNT/file")" -eq 3145728 ] || fail "extend truncate size" + +# Extended region must read back as zeros +tail -c 2097152 "$MNT/file" >"$WORK/tail" +head -c 2097152 /dev/zero >"$WORK/zeros" +cmp -s "$WORK/tail" "$WORK/zeros" || fail "extended region not zero-filled" + +truncate -s 0 "$MNT/file" +[ "$(stat -c %s "$MNT/file")" -eq 0 ] || fail "truncate to zero" + +ltfs_finish +echo "PASS" diff --git a/tests/t/03-rename.sh b/tests/t/03-rename.sh new file mode 100755 index 00000000..88783a75 --- /dev/null +++ b/tests/t/03-rename.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Rename files and directories, including overwriting an existing target. +. "${top_srcdir}/tests/lib/harness.sh" + +ltfs_setup + +echo one >"$MNT/a" +echo two >"$MNT/b" +mkdir "$MNT/dir1" +echo nested >"$MNT/dir1/inner" + +# Simple rename +mv "$MNT/a" "$MNT/a2" +[ "$(cat "$MNT/a2")" = "one" ] || fail "simple rename content" +[ ! -e "$MNT/a" ] || fail "source still present after rename" + +# Overwriting rename +mv "$MNT/a2" "$MNT/b" +[ "$(cat "$MNT/b")" = "one" ] || fail "overwriting rename content" + +# Directory rename +mv "$MNT/dir1" "$MNT/dir2" +[ "$(cat "$MNT/dir2/inner")" = "nested" ] || fail "directory rename content" + +# mv -n must not overwrite (rename(2) with RENAME_NOREPLACE on fuse3) +echo keep >"$MNT/target" +echo other >"$MNT/source" +mv -n "$MNT/source" "$MNT/target" 2>/dev/null || true +[ "$(cat "$MNT/target")" = "keep" ] || fail "mv -n overwrote existing target" + +ltfs_remount +[ "$(cat "$MNT/b")" = "one" ] || fail "rename result lost after remount" +[ "$(cat "$MNT/dir2/inner")" = "nested" ] || fail "dir rename lost after remount" + +ltfs_finish +echo "PASS" diff --git a/tests/t/04-dirs-unlink.sh b/tests/t/04-dirs-unlink.sh new file mode 100755 index 00000000..725652b1 --- /dev/null +++ b/tests/t/04-dirs-unlink.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# mkdir/rmdir/unlink semantics, including unlink of an open file. +. "${top_srcdir}/tests/lib/harness.sh" + +ltfs_setup + +mkdir -p "$MNT/d1/d2/d3" +[ -d "$MNT/d1/d2/d3" ] || fail "nested mkdir" + +echo data >"$MNT/d1/f" +rmdir "$MNT/d1" 2>/dev/null && fail "rmdir of non-empty directory succeeded" + +rm "$MNT/d1/f" +[ ! -e "$MNT/d1/f" ] || fail "unlink" + +rmdir "$MNT/d1/d2/d3" "$MNT/d1/d2" "$MNT/d1" +[ ! -e "$MNT/d1" ] || fail "rmdir chain" + +# Unlink while open: file content must stay readable through the open fd, +# and no .fuse_hidden* litter may remain (hard_remove semantics). +echo openme >"$MNT/openfile" +exec 3<"$MNT/openfile" +rm "$MNT/openfile" +[ ! -e "$MNT/openfile" ] || fail "unlink of open file did not remove the name" +ls -a "$MNT" | grep -q '\.fuse_hidden' && fail ".fuse_hidden file left behind" +exec 3<&- + +ltfs_finish +echo "PASS" diff --git a/tests/t/05-xattr.sh b/tests/t/05-xattr.sh new file mode 100755 index 00000000..07a6470a --- /dev/null +++ b/tests/t/05-xattr.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# Extended attribute set/get/list/remove, persistence across remount. +. "${top_srcdir}/tests/lib/harness.sh" + +ltfs_setup + +command -v setfattr >/dev/null 2>&1 || skip "attr tools not installed" + +echo content >"$MNT/file" + +setfattr -n user.test1 -v value1 "$MNT/file" +setfattr -n user.test2 -v value2 "$MNT/file" + +[ "$(getfattr --only-values -n user.test1 "$MNT/file")" = "value1" ] \ + || fail "getxattr value" + +listing=$(getfattr "$MNT/file" | grep '^user\.') +echo "$listing" | grep -q user.test1 || fail "listxattr missing user.test1" +echo "$listing" | grep -q user.test2 || fail "listxattr missing user.test2" + +setfattr -x user.test2 "$MNT/file" +getfattr "$MNT/file" 2>/dev/null | grep -q user.test2 && fail "removexattr" + +ltfs_remount +[ "$(getfattr --only-values -n user.test1 "$MNT/file")" = "value1" ] \ + || fail "xattr lost after remount" + +ltfs_finish +echo "PASS" diff --git a/tests/t/06-symlink.sh b/tests/t/06-symlink.sh new file mode 100755 index 00000000..8adeabfe --- /dev/null +++ b/tests/t/06-symlink.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Symlink creation, readlink, and persistence. +. "${top_srcdir}/tests/lib/harness.sh" + +ltfs_setup + +echo target-content >"$MNT/target" +ln -s target "$MNT/link" + +[ -L "$MNT/link" ] || fail "symlink not created" +[ "$(readlink "$MNT/link")" = "target" ] || fail "readlink value" +[ "$(cat "$MNT/link")" = "target-content" ] || fail "read through symlink" + +ln -s /nonexistent/absolute "$MNT/dangling" +[ "$(readlink "$MNT/dangling")" = "/nonexistent/absolute" ] || fail "dangling symlink" + +ltfs_remount +[ "$(readlink "$MNT/link")" = "target" ] || fail "symlink lost after remount" + +ltfs_finish +echo "PASS" diff --git a/tests/t/07-large-dir.sh b/tests/t/07-large-dir.sh new file mode 100755 index 00000000..3bf003fd --- /dev/null +++ b/tests/t/07-large-dir.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# readdir on a directory with many entries; attributes must match stat. +. "${top_srcdir}/tests/lib/harness.sh" + +ltfs_setup + +mkdir "$MNT/big" +i=0 +while [ $i -lt 500 ]; do + printf '%d' "$i" >"$MNT/big/f$i" + i=$((i + 1)) +done + +count=$(ls "$MNT/big" | wc -l) +[ "$count" -eq 500 ] || fail "expected 500 entries, got $count" + +# Attributes from listing (readdirplus on fuse3) must match per-file stat +ls_size=$(ls -l "$MNT/big/f123" | awk '{print $5}') +stat_size=$(stat -c %s "$MNT/big/f123") +[ "$ls_size" = "$stat_size" ] || fail "listing size != stat size" +[ "$stat_size" -eq 3 ] || fail "unexpected file size" + +ltfs_remount +count=$(ls "$MNT/big" | wc -l) +[ "$count" -eq 500 ] || fail "entries lost after remount" + +ltfs_finish +echo "PASS" diff --git a/tests/t/08-tar-roundtrip.sh b/tests/t/08-tar-roundtrip.sh new file mode 100755 index 00000000..e95a5f67 --- /dev/null +++ b/tests/t/08-tar-roundtrip.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Copy a small tree onto the volume, tar it back, and compare checksums. +. "${top_srcdir}/tests/lib/harness.sh" + +ltfs_setup + +# Build a source tree with a few subdirectories and binary files +mkdir -p "$WORK/tree/sub1/sub2" +dd if=/dev/urandom of="$WORK/tree/a.bin" bs=100K count=1 status=none +dd if=/dev/urandom of="$WORK/tree/sub1/b.bin" bs=300K count=1 status=none +dd if=/dev/urandom of="$WORK/tree/sub1/sub2/c.bin" bs=700K count=1 status=none +echo "text file" >"$WORK/tree/readme.txt" + +cp -r "$WORK/tree" "$MNT/tree" + +( cd "$WORK/tree" && find . -type f -exec sha256sum {} + | sort ) >"$WORK/src.sums" +( cd "$MNT/tree" && find . -type f -exec sha256sum {} + | sort ) >"$WORK/dst.sums" +cmp -s "$WORK/src.sums" "$WORK/dst.sums" || fail "tree copy checksum mismatch" + +# tar from the volume and extract elsewhere +tar -C "$MNT" -cf "$WORK/vol.tar" tree +mkdir "$WORK/extract" +tar -C "$WORK/extract" -xf "$WORK/vol.tar" +( cd "$WORK/extract/tree" && find . -type f -exec sha256sum {} + | sort ) >"$WORK/tar.sums" +cmp -s "$WORK/src.sums" "$WORK/tar.sums" || fail "tar roundtrip checksum mismatch" + +ltfs_finish +echo "PASS" diff --git a/tests/t/09-rename-flags-ftruncate.sh b/tests/t/09-rename-flags-ftruncate.sh new file mode 100755 index 00000000..5eae6014 --- /dev/null +++ b/tests/t/09-rename-flags-ftruncate.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# rename(2) flag semantics, ftruncate on an open fd, and inode stability. +. "${top_srcdir}/tests/lib/harness.sh" + +HELPER="$top_builddir/tests/helpers/fsops_helper" + +ltfs_setup + +[ -x "$HELPER" ] || fail "fsops_helper not built" + +echo one >"$MNT/a" +echo two >"$MNT/b" + +# RENAME_NOREPLACE with an existing target must fail and leave it intact +"$HELPER" noreplace "$MNT/a" "$MNT/b" && fail "RENAME_NOREPLACE overwrote target" +[ "$(cat "$MNT/b")" = "two" ] || fail "target changed by failed RENAME_NOREPLACE" +[ "$(cat "$MNT/a")" = "one" ] || fail "source changed by failed RENAME_NOREPLACE" + +# RENAME_EXCHANGE is not supported by LTFS; it must fail without touching data +"$HELPER" exchange "$MNT/a" "$MNT/b" && fail "RENAME_EXCHANGE unexpectedly succeeded" +[ "$(cat "$MNT/a")" = "one" ] || fail "source changed by failed RENAME_EXCHANGE" +[ "$(cat "$MNT/b")" = "two" ] || fail "target changed by failed RENAME_EXCHANGE" + +if ltfs_is_fuse3; then + # With a free target, RENAME_NOREPLACE must succeed (fuse2 cannot + # express rename flags at all, so this branch is fuse3-only) + "$HELPER" noreplace "$MNT/a" "$MNT/c" || fail "RENAME_NOREPLACE to free target failed" + [ "$(cat "$MNT/c")" = "one" ] || fail "content lost by RENAME_NOREPLACE" + [ ! -e "$MNT/a" ] || fail "source still present after RENAME_NOREPLACE" + mv "$MNT/c" "$MNT/a" +fi + +# ftruncate through an open descriptor +dd if=/dev/urandom of="$MNT/file" bs=1M count=1 status=none +out=$("$HELPER" ftruncate "$MNT/file" 12345) || fail "ftruncate failed: $out" +[ "$out" = "12345" ] || fail "ftruncate reported size $out" +[ "$(stat -c %s "$MNT/file")" -eq 12345 ] || fail "size after ftruncate" + +# Inode numbers come from the LTFS index (use_ino) and must survive a remount +ino_before=$(stat -c %i "$MNT/file") +ltfs_remount +ino_after=$(stat -c %i "$MNT/file") +[ "$ino_before" = "$ino_after" ] || fail "inode changed across remount ($ino_before -> $ino_after)" + +ltfs_finish +echo "PASS" diff --git a/tests/t/10-request-size.sh b/tests/t/10-request-size.sh new file mode 100755 index 00000000..86f7e3f8 --- /dev/null +++ b/tests/t/10-request-size.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# Verify the FUSE request sizes that reach the daemon. FUSE 3 builds +# negotiate 1 MiB requests (max_write/max_pages); FUSE 2 is limited to +# 128 KiB with big_writes. +. "${top_srcdir}/tests/lib/harness.sh" + +# DEBUG3 logging prints "FUSE write '...' (offset=..., count=...)" +LTFS_MOUNT_OPTS="-o verbose=6" + +ltfs_setup + +dd if=/dev/zero of="$MNT/big" bs=1M count=8 conv=fsync status=none + +# O_DIRECT reads bypass the readahead window, so the application's +# request size reaches the daemon (split at the negotiated maximum) +dd if="$MNT/big" of=/dev/null bs=1M iflag=direct status=none \ + || skip "O_DIRECT reads not supported on this kernel" + +ltfs_finish + +max_req() { + grep "FUSE $1" "$WORK/ltfs.log" | grep -o 'count=[0-9]*' | \ + cut -d= -f2 | sort -n | tail -1 +} + +write_max=$(max_req write) +read_max=$(max_req read) +echo "largest write request: ${write_max:-none}, largest read request: ${read_max:-none}" + +[ -n "$write_max" ] || fail "no write requests logged" + +if ltfs_is_fuse3; then + [ "$write_max" -ge 524288 ] || fail "write requests capped at $write_max bytes" + [ "$read_max" -ge 524288 ] || fail "read requests capped at $read_max bytes" +else + # big_writes raises the FUSE 2 limit to 128 KiB + [ "$write_max" -ge 65536 ] || fail "write requests capped at $write_max bytes" +fi + +echo "PASS" diff --git a/tests/t/11-direct-io.sh b/tests/t/11-direct-io.sh new file mode 100755 index 00000000..fabf01e0 --- /dev/null +++ b/tests/t/11-direct-io.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# -o direct_io: data integrity without the page cache, large requests +# even for buffered application I/O, and graceful mmap failure. +. "${top_srcdir}/tests/lib/harness.sh" + +HELPER="$top_builddir/tests/helpers/fsops_helper" + +LTFS_MOUNT_OPTS="-o direct_io -o verbose=6" + +ltfs_setup + +# Data integrity through the direct path, including odd sizes +dd if=/dev/urandom of="$WORK/data" bs=37k count=9 status=none +cp "$WORK/data" "$MNT/data" +cmp -s "$WORK/data" "$MNT/data" || fail "data mismatch while mounted" + +# mmap is not available on direct-I/O files; it must fail cleanly +out=$("$HELPER" mmap "$MNT/data" 4096) && fail "mmap unexpectedly succeeded" +echo "mmap failed as expected: $out" + +# Buffered writes from the application reach the daemon at the +# application's block size (no page-cache splitting) +dd if=/dev/zero of="$MNT/big" bs=1M count=4 status=none + +ltfs_remount +cmp -s "$WORK/data" "$MNT/data" || fail "data mismatch after remount" + +ltfs_finish + +if ltfs_is_fuse3; then + write_max=$(grep "FUSE write" "$WORK/ltfs.log" | grep -o 'count=[0-9]*' | \ + cut -d= -f2 | sort -n | tail -1) + echo "largest write request: $write_max" + [ "$write_max" -ge 524288 ] || fail "direct writes capped at $write_max bytes" +fi + +echo "PASS" diff --git a/tests/t/12-readdirplus.sh b/tests/t/12-readdirplus.sh new file mode 100755 index 00000000..216eef96 --- /dev/null +++ b/tests/t/12-readdirplus.sh @@ -0,0 +1,55 @@ +#!/bin/sh +# readdirplus: listing a directory must return correct attributes and, +# on FUSE 3, must not trigger a getattr request per entry. +. "${top_srcdir}/tests/lib/harness.sh" + +LTFS_MOUNT_OPTS="-o verbose=6" + +NFILES=100 + +ltfs_setup + +mkdir "$MNT/big" +i=0 +while [ $i -lt $NFILES ]; do + head -c $((i + 1)) /dev/zero >"$MNT/big/f$i" + i=$((i + 1)) +done + +# Remount so the listing below runs against a cold kernel cache +ltfs_remount + +# Attributes reported by the listing must match per-file stat +ls -l "$MNT/big" >"$WORK/listing" +for n in 0 57 99; do + ls_size=$(awk -v f="f$n" '$NF == f {print $5}' "$WORK/listing") + [ "$ls_size" = "$((n + 1))" ] || fail "listing reports size $ls_size for f$n" + stat_size=$(stat -c %s "$MNT/big/f$n") + [ "$stat_size" = "$((n + 1))" ] || fail "stat reports size $stat_size for f$n" +done + +ltfs_finish + +# "FUSE getattr/fgetattr" debug lines from the remounted instance show how +# many attribute requests the listing needed +getattrs=$(grep -c "FUSE f*getattr" "$WORK/ltfs-remount.log" || true) +echo "getattr requests during ls -l of $NFILES files: $getattrs" + +if ltfs_is_fuse3; then + # readdirplus delivers attributes with the listing; without it the + # kernel issues one getattr (via lookup) per entry. The prefill is + # only effective with libfuse >= 3.17 (verified there; libfuse 3.14 + # never sends READDIRPLUS to the high-level API), so the strict + # assertion is gated on the runtime library version. + ver=$(fusermount3 -V 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1) + maj=${ver%%.*} + min=${ver#*.} + if [ "${maj:-0}" -gt 3 ] || { [ "${maj:-0}" -eq 3 ] && [ "${min:-0}" -ge 17 ]; }; then + [ "$getattrs" -lt $((NFILES / 2)) ] \ + || fail "expected readdirplus to suppress per-entry getattr, saw $getattrs" + else + echo "libfuse ${ver:-unknown}: readdirplus prefill not asserted (verified on >= 3.17)" + fi +fi + +echo "PASS" diff --git a/tests/t/13-large-write-integrity.sh b/tests/t/13-large-write-integrity.sh new file mode 100755 index 00000000..c3631096 --- /dev/null +++ b/tests/t/13-large-write-integrity.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Data integrity for single write() calls larger than the tape block size. +# +# A single large write exercises the I/O scheduler's request-splitting: the +# write must be broken into block-sized cache entries without dropping the +# tail. dd with a large bs issues one write() syscall per block, so with the +# FUSE 3 default max_write of 1 MiB (> the 512 KiB tape block) the daemon +# receives multi-block writes in a single request. Content is random and +# verified byte-for-byte, before and after a remount. +. "${top_srcdir}/tests/lib/harness.sh" + +ltfs_setup + +# One write() each, all larger than the block size, at offset 0 and beyond +for mb in 1 4 8; do + dd if=/dev/urandom of="$WORK/src$mb" bs=${mb}M count=1 status=none + dd if="$WORK/src$mb" of="$MNT/f$mb" bs=${mb}M count=1 conv=fsync status=none + cmp -s "$WORK/src$mb" "$MNT/f$mb" || fail "${mb} MiB single write corrupted while mounted" +done + +# Overwrite the second half of the 4 MiB file with one 2 MiB write at a +# block-aligned non-zero offset (exercises the insert-into-list path). +dd if=/dev/urandom of="$WORK/mid" bs=2M count=1 status=none +dd if="$WORK/mid" of="$MNT/f4" bs=2M count=1 seek=1 conv=fsync,notrunc status=none +head -c 2097152 "$WORK/src4" >"$WORK/f4.expect" +cat "$WORK/mid" >>"$WORK/f4.expect" +cmp -s "$WORK/f4.expect" "$MNT/f4" || fail "large write at non-zero offset corrupted" + +ltfs_remount + +cmp -s "$WORK/src8" "$MNT/f8" || fail "8 MiB write corrupted after remount" +cmp -s "$WORK/f4.expect" "$MNT/f4" || fail "offset write corrupted after remount" + +ltfs_finish +echo "PASS" From d5f14f3b967763b7ba98c22e5d58894d7790a076 Mon Sep 17 00:00:00 2001 From: Matej Kenda Date: Fri, 12 Jun 2026 11:07:43 +0200 Subject: [PATCH 3/3] ci: run the integration test suite on every push Single job building with autotools and running make check; the suite needs /dev/fuse, which the hosted runners provide. --- .github/workflows/test.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..6c9acb3f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Test Suite + +on: [push, pull_request] + +jobs: + test: + name: Build and test + runs-on: ubuntu-latest + + steps: + - name: Set up Git repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies + run: | + sudo apt-get -q update + sudo apt-get -q -y install --no-install-recommends \ + build-essential automake autoconf libtool pkg-config \ + icu-devtools libicu-dev libxml2-dev uuid-dev libsnmp-dev \ + libfuse-dev attr + sudo install -m 755 tests/docker/icu-config /usr/local/bin/icu-config + + - name: Build + run: | + ./autogen.sh + ./configure --enable-icu-6x + make -j"$(nproc)" + + - name: Run tests + run: make check VERBOSE=1 || { cat tests/test-suite.log; exit 1; }