From 953c3d8787d63aa233911611304d4b5656dc5e6d Mon Sep 17 00:00:00 2001 From: Matej Kenda Date: Wed, 10 Jun 2026 22:23:08 +0200 Subject: [PATCH 1/7] fix: pass file permissions to open(2) in arch_open on POSIX The share flag was passed as the mode, so the file backend created 0200 files that a non-root user could not reopen. --- src/libltfs/arch/ltfs_arch_ops.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libltfs/arch/ltfs_arch_ops.h b/src/libltfs/arch/ltfs_arch_ops.h index 242d6dc8..097b70c6 100644 --- a/src/libltfs/arch/ltfs_arch_ops.h +++ b/src/libltfs/arch/ltfs_arch_ops.h @@ -151,7 +151,7 @@ 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) + #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 7287e47cf2ff22cb5cb823def488ea9aa766dcfa Mon Sep 17 00:00:00 2001 From: Matej Kenda Date: Fri, 12 Jun 2026 12:58:16 +0200 Subject: [PATCH 2/7] Add an integration test suite using the file tape backend Shell tests under make check and ctest; skipped without /dev/fuse. tests/run-in-docker.sh runs them in a container on non-Linux hosts. --- .gitignore | 5 + 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, 815 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 295a99da..612b0d2c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,8 @@ GTAGS TAGS tags *~ +# Files generated by make check +tests/t/*.log +tests/t/*.trs +tests/test-suite.log +tests/helpers/fsops_helper diff --git a/Makefile.am b/Makefile.am index f7cec8cd..b743ee10 100644 --- a/Makefile.am +++ b/Makefile.am @@ -38,7 +38,7 @@ ACLOCAL_AMFLAGS = -I m4 nobase_pkginclude_HEADERS = config.h EXTRA_DIST = VERSION -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 3e4ec856..55c7977b 100644 --- a/configure.ac +++ b/configure.ac @@ -619,6 +619,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 @@ -637,6 +642,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 7e0d6f49bf83170029d7d990ec9635ce44a2466b Mon Sep 17 00:00:00 2001 From: Matej Kenda Date: Fri, 12 Jun 2026 11:07:43 +0200 Subject: [PATCH 3/7] ci: run the integration test suite on every push --- .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; } From 513ad7788800d2099f14bb76a0c351be91a935f7 Mon Sep 17 00:00:00 2001 From: Matej Kenda Date: Tue, 13 Oct 2026 10:43:10 +0200 Subject: [PATCH 4/7] tests: fix checks that could not fail, cleanup and the docker runner --- .github/workflows/test.yml | 8 +- tests/Makefile.am | 43 ++++++++-- tests/docker/Dockerfile | 6 +- tests/docker/icu-config | 12 --- tests/helpers/fsops_helper.c | 120 ++++++++++++++++++++++----- tests/lib/harness.sh | 49 +++++++++-- tests/run-in-docker.sh | 23 +++-- tests/t/01-io-roundtrip.sh | 14 ++-- tests/t/02-truncate.sh | 6 +- tests/t/04-dirs-unlink.sh | 2 + tests/t/05-xattr.sh | 8 +- tests/t/09-rename-flags-ftruncate.sh | 6 +- tests/t/10-request-size.sh | 11 ++- tests/t/11-direct-io.sh | 5 +- tests/t/12-readdirplus.sh | 35 ++++---- 15 files changed, 244 insertions(+), 104 deletions(-) delete mode 100644 tests/docker/icu-config diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6c9acb3f..54a5630e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,10 +6,11 @@ jobs: test: name: Build and test runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Set up Git repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: recursive @@ -20,13 +21,12 @@ jobs: 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 + ./configure make -j"$(nproc)" - name: Run tests - run: make check VERBOSE=1 || { cat tests/test-suite.log; exit 1; } + run: make check VERBOSE=1 diff --git a/tests/Makefile.am b/tests/Makefile.am index 9015f115..692294c9 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -1,3 +1,38 @@ +# +# +# OO_Copyright_BEGIN +# +# +# Copyright 2010, 2020 IBM Corp. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS IS'' +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +# +# OO_Copyright_END +# + # # Integration tests for LTFS using the file tape backend. # @@ -16,16 +51,12 @@ AM_TESTS_ENVIRONMENT = \ 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_LIST is set by configure from tests/t/*.sh; GNU make functions +# cannot be used because automake runs with -Wall -Werror. 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 index 3e2b774c..3801cece 100644 --- a/tests/docker/Dockerfile +++ b/tests/docker/Dockerfile @@ -9,8 +9,4 @@ RUN apt-get -q update && apt-get -q -y install --no-install-recommends \ 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 +WORKDIR /build diff --git a/tests/docker/icu-config b/tests/docker/icu-config deleted file mode 100644 index c6af7cb8..00000000 --- a/tests/docker/icu-config +++ /dev/null @@ -1,12 +0,0 @@ -#!/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 index 7bb6e4c1..d95e7cdd 100644 --- a/tests/helpers/fsops_helper.c +++ b/tests/helpers/fsops_helper.c @@ -1,12 +1,50 @@ +/* +** +** OO_Copyright_BEGIN +** +** +** Copyright 2010, 2025 IBM Corp. All rights reserved. +** +** Redistribution and use in source and binary forms, with or without +** modification, are permitted provided that the following conditions +** are met: +** 1. Redistributions of source code must retain the above copyright +** notice, this list of conditions and the following disclaimer. +** 2. Redistributions in binary form must reproduce the above copyright +** notice, this list of conditions and the following disclaimer in the +** documentation and/or other materials provided with the distribution. +** 3. Neither the name of the copyright holder nor the names of its +** contributors may be used to endorse or promote products derived from +** this software without specific prior written permission. +** +** THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS IS'' +** AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +** IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +** ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +** LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +** CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +** SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +** INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +** CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +** ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +** POSSIBILITY OF SUCH DAMAGE. +** +** +** OO_Copyright_END +*/ + /* Test helper exercising syscalls that shell utilities do not reach - * directly: renameat2() flags and ftruncate() on an open descriptor. + * directly: renameat2() flags, ftruncate() on an open descriptor and + * mmap(). * * 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 + * fsops_helper mmap map the file shared and writable * - * Exit codes: 0 = success, 2 = syscall failed (errno printed), 3 = usage. + * Exit codes: 0 = success, 2 = syscall failed (strerror printed on stdout), + * 3 = usage, 4 = not supported on this platform. */ #ifndef _GNU_SOURCE #define _GNU_SOURCE @@ -16,49 +54,89 @@ #include #include #include +#include #include #include +static int usage(const char *prog) +{ + fprintf(stderr, "usage: %s noreplace|exchange|ftruncate|mmap \n", + prog); + return 3; +} + +static int parse_len(const char *arg, long long *len) +{ + char *end; + + *len = strtoll(arg, &end, 10); + return end != arg && *end == '\0'; +} + +/* Print the error before close() can change errno. */ +static int syscall_failed(int fd) +{ + printf("%s\n", strerror(errno)); + if (fd >= 0) + close(fd); + return 2; +} + int main(int argc, char **argv) { - if (argc != 4) { - fprintf(stderr, "usage: %s noreplace|exchange|ftruncate \n", - argv[0]); - return 3; - } + if (argc != 4) + return usage(argv[0]); if (!strcmp(argv[1], "noreplace") || !strcmp(argv[1], "exchange")) { -#ifdef __linux__ + /* renameat2() and RENAME_* need glibc 2.28 or later. */ +#if defined(__linux__) && defined(RENAME_NOREPLACE) 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; - } + if (renameat2(AT_FDCWD, argv[2], AT_FDCWD, argv[3], flags) < 0) + return syscall_failed(-1); 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; + return 4; #endif } if (!strcmp(argv[1], "ftruncate")) { struct stat st; - off_t len = strtoll(argv[3], NULL, 10); - int fd = open(argv[2], O_RDWR); + long long len; + int fd; - if (fd < 0 || ftruncate(fd, len) < 0 || fstat(fd, &st) < 0) { - printf("%s\n", strerror(errno)); - return 2; - } + if (!parse_len(argv[3], &len)) + return usage(argv[0]); + fd = open(argv[2], O_RDWR); + if (fd < 0) + return syscall_failed(-1); + if (ftruncate(fd, len) < 0 || fstat(fd, &st) < 0) + return syscall_failed(fd); printf("%lld\n", (long long)st.st_size); close(fd); return 0; } + if (!strcmp(argv[1], "mmap")) { + long long len; + void *addr; + int fd; + + if (!parse_len(argv[3], &len)) + return usage(argv[0]); + fd = open(argv[2], O_RDWR); + if (fd < 0) + return syscall_failed(-1); + addr = mmap(NULL, (size_t)len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (addr == MAP_FAILED) + return syscall_failed(fd); + munmap(addr, (size_t)len); + 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 index 1b7dab8e..51281bdb 100644 --- a/tests/lib/harness.sh +++ b/tests/lib/harness.sh @@ -9,6 +9,9 @@ set -eu +# Error texts are compared literally. +export LC_ALL=C + SKIP=77 : "${top_builddir:?top_builddir must be set (run via make check)}" @@ -17,6 +20,7 @@ SKIP=77 LTFS_BIN="$top_builddir/src/ltfs" MKLTFS_BIN="$top_builddir/src/utils/mkltfs" LTFSCK_BIN="$top_builddir/src/utils/ltfsck" +HELPER="$top_builddir/tests/helpers/fsops_helper" # Extra mount options for the ltfs invocation; tests may override. LTFS_MOUNT_OPTS="${LTFS_MOUNT_OPTS:-}" @@ -36,6 +40,19 @@ fail() { exit 1 } +# helper_fails : the fsops_helper +# syscall must fail with exactly that error text. +helper_fails() { + [ -x "$HELPER" ] || fail "fsops_helper not built" + expected=$1 + shift + rc=0 + out=$("$HELPER" "$@") || rc=$? + [ "$rc" -eq 4 ] && skip "fsops_helper $* is not supported on this platform" + [ "$rc" -eq 2 ] || fail "fsops_helper $*: exit code $rc, output: $out" + [ "$out" = "$expected" ] || fail "fsops_helper $*: expected '$expected', got '$out'" +} + ltfs_check_env() { [ "$(uname -s)" = "Linux" ] || skip "FUSE integration tests only run on Linux" [ -e /dev/fuse ] || skip "/dev/fuse is not available" @@ -62,7 +79,8 @@ 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 + _fusermount -u "$MNT" 2>/dev/null || _fusermount -u -z "$MNT" 2>/dev/null \ + || umount -l "$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. @@ -71,6 +89,12 @@ ltfs_cleanup() { sleep 0.2 done kill "$LTFS_PID" 2>/dev/null + for _ in $(seq 25); do + kill -0 "$LTFS_PID" 2>/dev/null || break + sleep 0.2 + done + kill -9 "$LTFS_PID" 2>/dev/null + wait "$LTFS_PID" fi [ -n "$WORK" ] && rm -rf "$WORK" exit "$status" @@ -94,12 +118,17 @@ _find_plugin() { # Generate an ltfs.conf pointing at the plugins in the build tree. _write_config() { + tape_file=$(_find_plugin tape_drivers/generic/file libtape-file) + iosched_unified=$(_find_plugin iosched libiosched-unified) + iosched_fcfs=$(_find_plugin iosched libiosched-fcfs) + kmi_flatfile=$(_find_plugin kmi libkmi-flatfile) + kmi_simple=$(_find_plugin kmi libkmi-simple) cat >"$WORK/ltfs.conf" <"$WORK/ltfsck.log" 2>&1 || rc=$? diff --git a/tests/run-in-docker.sh b/tests/run-in-docker.sh index fe933f55..0233e2b2 100755 --- a/tests/run-in-docker.sh +++ b/tests/run-in-docker.sh @@ -3,9 +3,10 @@ # 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 +# e.g. tests/run-in-docker.sh --enable-fast # Environment: # LTFS_DOCKER_SHELL=1 drop into an interactive shell instead of building +# (the source tree is mounted read-only at /src) set -eu @@ -14,17 +15,21 @@ 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" +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 + exec docker run $run_flags -it -v "$top_srcdir:/src:ro" "$image" bash fi +# Build from a copy of the source files so the host tree stays untouched. # 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 -" +cd "$top_srcdir" && git ls-files -z --cached --others --exclude-standard \ + | COPYFILE_DISABLE=1 tar --null -T - -cf - \ + | docker run $run_flags -i "$image" sh -ec ' + mkdir -p /build && cd /build && tar -xf - + ./autogen.sh + ./configure "$@" + make -j"$(nproc)" + make check VERBOSE=1 + ' sh "$@" diff --git a/tests/t/01-io-roundtrip.sh b/tests/t/01-io-roundtrip.sh index 61531ee9..0b87f24c 100755 --- a/tests/t/01-io-roundtrip.sh +++ b/tests/t/01-io-roundtrip.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Write files of various sizes, verify checksums before and after remount. +# Write files of various sizes, verify contents before and after remount. . "${top_srcdir}/tests/lib/harness.sh" ltfs_setup @@ -11,6 +11,11 @@ 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 +# Expected contents after the overwrite and the append below +cp "$WORK/small" "$WORK/small.expect" +printf 'rewrite' | dd of="$WORK/small.expect" bs=1 seek=10 conv=notrunc status=none +cat "$WORK/medium" "$WORK/small" >"$WORK/medium.expect" + for f in small medium large; do cp "$WORK/$f" "$MNT/data/$f" done @@ -25,10 +30,9 @@ 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" +cmp -s "$WORK/small.expect" "$MNT/data/small" || fail "small differs after remount" +cmp -s "$WORK/medium.expect" "$MNT/data/medium" || fail "medium differs after remount" +cmp -s "$WORK/large" "$MNT/data/large" || fail "large differs after remount" ltfs_finish echo "PASS" diff --git a/tests/t/02-truncate.sh b/tests/t/02-truncate.sh index 9055eb05..f02c7d77 100755 --- a/tests/t/02-truncate.sh +++ b/tests/t/02-truncate.sh @@ -4,13 +4,17 @@ ltfs_setup -dd if=/dev/urandom of="$MNT/file" bs=1M count=2 status=none +dd if=/dev/urandom of="$WORK/source" bs=1M count=2 status=none +head -c 1048576 "$WORK/source" >"$WORK/head" +cp "$WORK/source" "$MNT/file" truncate -s 1M "$MNT/file" [ "$(stat -c %s "$MNT/file")" -eq 1048576 ] || fail "shrink truncate size" +cmp -s "$WORK/head" "$MNT/file" || fail "content changed by shrink truncate" truncate -s 3M "$MNT/file" [ "$(stat -c %s "$MNT/file")" -eq 3145728 ] || fail "extend truncate size" +head -c 1048576 "$MNT/file" | cmp -s "$WORK/head" - || fail "content changed by extend truncate" # Extended region must read back as zeros tail -c 2097152 "$MNT/file" >"$WORK/tail" diff --git a/tests/t/04-dirs-unlink.sh b/tests/t/04-dirs-unlink.sh index 725652b1..2f15c487 100755 --- a/tests/t/04-dirs-unlink.sh +++ b/tests/t/04-dirs-unlink.sh @@ -23,6 +23,8 @@ 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" +IFS= read -r line <&3 || fail "open file unreadable after unlink" +[ "$line" = "openme" ] || fail "open file reads '$line' after unlink" exec 3<&- ltfs_finish diff --git a/tests/t/05-xattr.sh b/tests/t/05-xattr.sh index 07a6470a..febc11db 100755 --- a/tests/t/05-xattr.sh +++ b/tests/t/05-xattr.sh @@ -2,10 +2,10 @@ # 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" +ltfs_setup + echo content >"$MNT/file" setfattr -n user.test1 -v value1 "$MNT/file" @@ -14,12 +14,12 @@ 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\.') +listing=$(getfattr "$MNT/file") || fail "listxattr failed" 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" +getfattr -n user.test2 "$MNT/file" >/dev/null 2>&1 && fail "removexattr: user.test2 still present" ltfs_remount [ "$(getfattr --only-values -n user.test1 "$MNT/file")" = "value1" ] \ diff --git a/tests/t/09-rename-flags-ftruncate.sh b/tests/t/09-rename-flags-ftruncate.sh index 5eae6014..d4e63db6 100755 --- a/tests/t/09-rename-flags-ftruncate.sh +++ b/tests/t/09-rename-flags-ftruncate.sh @@ -2,8 +2,6 @@ # 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" @@ -12,12 +10,12 @@ 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" +helper_fails "File exists" noreplace "$MNT/a" "$MNT/b" [ "$(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" +helper_fails "Invalid argument" exchange "$MNT/a" "$MNT/b" [ "$(cat "$MNT/a")" = "one" ] || fail "source changed by failed RENAME_EXCHANGE" [ "$(cat "$MNT/b")" = "two" ] || fail "target changed by failed RENAME_EXCHANGE" diff --git a/tests/t/10-request-size.sh b/tests/t/10-request-size.sh index 86f7e3f8..9fd69a31 100755 --- a/tests/t/10-request-size.sh +++ b/tests/t/10-request-size.sh @@ -13,8 +13,13 @@ 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" +err=$(dd if="$MNT/big" of=/dev/null bs=1M iflag=direct status=none 2>&1) || { + case $err in + *"failed to open"*"Invalid argument"*) + skip "O_DIRECT is not supported on this kernel" ;; + esac + fail "O_DIRECT read failed: $err" +} ltfs_finish @@ -34,7 +39,7 @@ if ltfs_is_fuse3; then [ "$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" + [ "$write_max" -ge 131072 ] || 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 index fabf01e0..da7fd3c6 100755 --- a/tests/t/11-direct-io.sh +++ b/tests/t/11-direct-io.sh @@ -3,8 +3,6 @@ # 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 @@ -15,8 +13,7 @@ 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" +helper_fails "No such device" mmap "$MNT/data" 4096 # Buffered writes from the application reach the daemon at the # application's block size (no page-cache splitting) diff --git a/tests/t/12-readdirplus.sh b/tests/t/12-readdirplus.sh index 216eef96..7f2cc36f 100755 --- a/tests/t/12-readdirplus.sh +++ b/tests/t/12-readdirplus.sh @@ -1,12 +1,15 @@ #!/bin/sh -# readdirplus: listing a directory must return correct attributes and, -# on FUSE 3, must not trigger a getattr request per entry. +# readdirplus: listing a directory must return correct attributes and +# must not trigger a getattr request per entry. . "${top_srcdir}/tests/lib/harness.sh" LTFS_MOUNT_OPTS="-o verbose=6" NFILES=100 +ltfs_check_env +ltfs_is_fuse3 || skip "readdirplus needs a FUSE 3 build" + ltfs_setup mkdir "$MNT/big" @@ -35,21 +38,19 @@ ltfs_finish 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 +# 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 echo "PASS" From e8b38d67a12dff049dff5cad91c38fe35f55d8a1 Mon Sep 17 00:00:00 2001 From: Matej Kenda Date: Tue, 13 Oct 2026 11:10:22 +0200 Subject: [PATCH 5/7] tests: tighten t/05 and t/10 and protect cleanup from signals run-in-docker.sh also works from a release tarball without git. --- tests/lib/harness.sh | 1 + tests/run-in-docker.sh | 10 +++++++--- tests/t/05-xattr.sh | 4 +++- tests/t/10-request-size.sh | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/lib/harness.sh b/tests/lib/harness.sh index 51281bdb..7d38689f 100644 --- a/tests/lib/harness.sh +++ b/tests/lib/harness.sh @@ -77,6 +77,7 @@ _fusermount() { ltfs_cleanup() { status=$? + trap '' INT TERM set +e if [ -n "$MNT" ] && mountpoint -q "$MNT" 2>/dev/null; then _fusermount -u "$MNT" 2>/dev/null || _fusermount -u -z "$MNT" 2>/dev/null \ diff --git a/tests/run-in-docker.sh b/tests/run-in-docker.sh index 0233e2b2..1d0ac4c0 100755 --- a/tests/run-in-docker.sh +++ b/tests/run-in-docker.sh @@ -23,10 +23,14 @@ if [ "${LTFS_DOCKER_SHELL:-0}" = "1" ]; then fi # Build from a copy of the source files so the host tree stays untouched. +cd "$top_srcdir" # shellcheck disable=SC2086 -cd "$top_srcdir" && git ls-files -z --cached --others --exclude-standard \ - | COPYFILE_DISABLE=1 tar --null -T - -cf - \ - | docker run $run_flags -i "$image" sh -ec ' +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git ls-files -z --cached --others --exclude-standard \ + | COPYFILE_DISABLE=1 tar --null -T - -cf - +else + COPYFILE_DISABLE=1 tar -cf - . +fi | docker run $run_flags -i "$image" sh -ec ' mkdir -p /build && cd /build && tar -xf - ./autogen.sh ./configure "$@" diff --git a/tests/t/05-xattr.sh b/tests/t/05-xattr.sh index febc11db..e04bc6ca 100755 --- a/tests/t/05-xattr.sh +++ b/tests/t/05-xattr.sh @@ -19,7 +19,9 @@ 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 -n user.test2 "$MNT/file" >/dev/null 2>&1 && fail "removexattr: user.test2 still present" +listing=$(getfattr "$MNT/file") || fail "listxattr failed after removexattr" +echo "$listing" | grep -q user.test2 && fail "removexattr: user.test2 still present" +echo "$listing" | grep -q user.test1 || fail "removexattr also removed user.test1" ltfs_remount [ "$(getfattr --only-values -n user.test1 "$MNT/file")" = "value1" ] \ diff --git a/tests/t/10-request-size.sh b/tests/t/10-request-size.sh index 9fd69a31..6783430b 100755 --- a/tests/t/10-request-size.sh +++ b/tests/t/10-request-size.sh @@ -15,7 +15,7 @@ dd if=/dev/zero of="$MNT/big" bs=1M count=8 conv=fsync status=none # request size reaches the daemon (split at the negotiated maximum) err=$(dd if="$MNT/big" of=/dev/null bs=1M iflag=direct status=none 2>&1) || { case $err in - *"failed to open"*"Invalid argument"*) + *"failed to open"*"Invalid argument"* | *"failed to open"*"Invalid input"*) skip "O_DIRECT is not supported on this kernel" ;; esac fail "O_DIRECT read failed: $err" From 9021b0c304effae5006f68a46b8bae948e43e3d3 Mon Sep 17 00:00:00 2001 From: Matej Kenda Date: Tue, 13 Oct 2026 14:03:39 +0200 Subject: [PATCH 6/7] tests: add regression tests for O_TRUNC, timestamps and listing during writes --- tests/helpers/fsops_helper.c | 22 +++++++-- tests/t/14-overwrite-truncate.sh | 37 +++++++++++++++ tests/t/15-timestamps.sh | 74 +++++++++++++++++++++++++++++ tests/t/16-listing-during-writes.sh | 57 ++++++++++++++++++++++ 4 files changed, 187 insertions(+), 3 deletions(-) create mode 100755 tests/t/14-overwrite-truncate.sh create mode 100755 tests/t/15-timestamps.sh create mode 100755 tests/t/16-listing-during-writes.sh diff --git a/tests/helpers/fsops_helper.c b/tests/helpers/fsops_helper.c index d95e7cdd..8f1c9485 100644 --- a/tests/helpers/fsops_helper.c +++ b/tests/helpers/fsops_helper.c @@ -34,14 +34,16 @@ */ /* Test helper exercising syscalls that shell utilities do not reach - * directly: renameat2() flags, ftruncate() on an open descriptor and - * mmap(). + * directly: renameat2() flags, ftruncate() on an open descriptor, mmap() + * and utimensat() with UTIME_OMIT. * * 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 * fsops_helper mmap map the file shared and writable + * fsops_helper atime set atime, UTIME_OMIT for mtime + * fsops_helper mtime set mtime, UTIME_OMIT for atime * * Exit codes: 0 = success, 2 = syscall failed (strerror printed on stdout), * 3 = usage, 4 = not supported on this platform. @@ -60,7 +62,7 @@ static int usage(const char *prog) { - fprintf(stderr, "usage: %s noreplace|exchange|ftruncate|mmap \n", + fprintf(stderr, "usage: %s noreplace|exchange|ftruncate|mmap|atime|mtime \n", prog); return 3; } @@ -137,6 +139,20 @@ int main(int argc, char **argv) return 0; } + if (!strcmp(argv[1], "atime") || !strcmp(argv[1], "mtime")) { + struct timespec ts[2] = { { 0, UTIME_OMIT }, { 0, UTIME_OMIT } }; + int i = !strcmp(argv[1], "mtime"); + long long sec; + + if (!parse_len(argv[3], &sec)) + return usage(argv[0]); + ts[i].tv_sec = (time_t)sec; + ts[i].tv_nsec = 0; + if (utimensat(AT_FDCWD, argv[2], ts, 0) < 0) + return syscall_failed(-1); + return 0; + } + fprintf(stderr, "unknown command: %s\n", argv[1]); return 3; } diff --git a/tests/t/14-overwrite-truncate.sh b/tests/t/14-overwrite-truncate.sh new file mode 100755 index 00000000..42237e50 --- /dev/null +++ b/tests/t/14-overwrite-truncate.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Overwriting a file with shorter content must truncate it (O_TRUNC). +. "${top_srcdir}/tests/lib/harness.sh" + +# check_file +check_file() { + size=$(stat -c %s "$MNT/$1") + content=$(cat "$MNT/$1") + [ "$size" -eq ${#2} ] && [ "$content" = "$2" ] \ + || fail "$1 $3: expected '$2' (${#2} bytes), got '$content' ($size bytes)" +} + +ltfs_setup + +printf 0123456789 >"$MNT/redirect" +printf abc >"$MNT/redirect" + +printf 0123456789 >"$WORK/long" +printf xyz >"$WORK/short" +cp "$WORK/long" "$MNT/copy" +cp "$WORK/short" "$MNT/copy" + +printf 0123456789 >"$MNT/append" +printf abc >>"$MNT/append" + +check_file redirect abc "after overwrite by redirection" +check_file copy xyz "after overwrite by cp" +check_file append 0123456789abc "after append" + +ltfs_remount + +check_file redirect abc "after overwrite by redirection and remount" +check_file copy xyz "after overwrite by cp and remount" +check_file append 0123456789abc "after append and remount" + +ltfs_finish +echo "PASS" diff --git a/tests/t/15-timestamps.sh b/tests/t/15-timestamps.sh new file mode 100755 index 00000000..cb1892e8 --- /dev/null +++ b/tests/t/15-timestamps.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# touch must store the given date, or the current time, on new and existing +# files; an update of one time must keep the other. +. "${top_srcdir}/tests/lib/harness.sh" + +DATE=2020-01-01T00:00:00 +DATE_A=2021-02-03T04:05:06 +DATE_M=2022-03-04T05:06:07 + +# check_now : the stat field is within 5 minutes of $now +check_now() { + t=$(stat -c "$2" "$MNT/$1") + [ $((t - now)) -ge -300 ] && [ $((t - now)) -le 300 ] \ + || fail "$1: $3 is $t, expected the current time $now" +} + +# check_times : the file has exactly these times +check_times() { + atime=$(stat -c %X "$MNT/$1") + mtime=$(stat -c %Y "$MNT/$1") + [ "$atime" -eq "$2" ] && [ "$mtime" -eq "$3" ] \ + || fail "$1: atime $atime, mtime $mtime after $4, expected $2 and $3" +} + +ltfs_setup + +date_epoch=$(date -d "$DATE" +%s) +date_a_epoch=$(date -d "$DATE_A" +%s) +date_m_epoch=$(date -d "$DATE_M" +%s) + +printf data >"$MNT/dated" +printf data >"$MNT/existing" +touch -d "$DATE" "$MNT/dated" "$MNT/existing" +for f in dated existing; do + check_times "$f" "$date_epoch" "$date_epoch" "touch -d $DATE" +done + +now=$(date +%s) +touch "$MNT/existing" +check_now existing %Y "mtime after touch of an existing file" +check_now existing %X "atime after touch of an existing file" + +touch "$MNT/new" +check_now new %Y "mtime of a file created by touch" + +# Set one time and pass UTIME_OMIT for the other, which touch -a and -m do +# not always do. libfuse 2 drops such updates because LTFS does not set +# flag_utime_omit_ok. +if ltfs_is_fuse3; then + [ -x "$HELPER" ] || fail "fsops_helper not built" + printf data >"$MNT/partial" + touch -d "$DATE" "$MNT/partial" + "$HELPER" atime "$MNT/partial" "$date_a_epoch" || fail "partial: atime update failed" + check_times partial "$date_a_epoch" "$date_epoch" "an atime-only update" + "$HELPER" mtime "$MNT/partial" "$date_m_epoch" || fail "partial: mtime update failed" + check_times partial "$date_a_epoch" "$date_m_epoch" "an mtime-only update" +fi + +existing_mtime=$(stat -c %Y "$MNT/existing") +existing_atime=$(stat -c %X "$MNT/existing") +new_mtime=$(stat -c %Y "$MNT/new") + +ltfs_remount + +[ "$(stat -c %Y "$MNT/dated")" -eq "$date_epoch" ] || fail "dated: mtime changed by remount" +[ "$(stat -c %Y "$MNT/existing")" -eq "$existing_mtime" ] || fail "existing: mtime changed by remount" +[ "$(stat -c %X "$MNT/existing")" -eq "$existing_atime" ] || fail "existing: atime changed by remount" +[ "$(stat -c %Y "$MNT/new")" -eq "$new_mtime" ] || fail "new: mtime changed by remount" +if ltfs_is_fuse3; then + check_times partial "$date_a_epoch" "$date_m_epoch" "remount" +fi + +ltfs_finish +echo "PASS" diff --git a/tests/t/16-listing-during-writes.sh b/tests/t/16-listing-during-writes.sh new file mode 100755 index 00000000..8bfb9615 --- /dev/null +++ b/tests/t/16-listing-during-writes.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Writing and closing files while a large directory is listed must not deadlock. +. "${top_srcdir}/tests/lib/harness.sh" + +NENTRIES=1000 +NWRITES=300 + +# stop_deadlocked : stop the workers, detach the mount and fail +stop_deadlocked() { + kill "$LS_PID" "$WR_PID" 2>/dev/null || true + # A stat of the mount point would block on the deadlocked daemon + # too; detach the mount so that the cleanup goes on to kill it. + _fusermount -u -z "$MNT" 2>/dev/null || umount -l "$MNT" 2>/dev/null || true + fail "$1 did not finish within 120 s (deadlock)" +} + +# wait_pid : wait up to 120 s, in 0.2 s steps, for the process to end +wait_pid() { + n=0 + while kill -0 "$1" 2>/dev/null; do + [ $n -lt 600 ] || stop_deadlocked "$2" + sleep 0.2 + n=$((n + 1)) + done +} + +ltfs_setup + +mkdir "$MNT/big" "$MNT/w" +i=0 +while [ $i -lt $NENTRIES ]; do + : >"$MNT/big/e$i" + i=$((i + 1)) +done + +while [ ! -e "$WORK/stop" ]; do + ls -l "$MNT/big" >/dev/null +done & +LS_PID=$! + +( + i=0 + while [ $i -lt $NWRITES ]; do + head -c 100000 /dev/urandom >"$MNT/w/f$i" + i=$((i + 1)) + done +) & +WR_PID=$! + +wait_pid "$WR_PID" "writing files while listing" +: >"$WORK/stop" +wait_pid "$LS_PID" "listing after the writes" +wait "$LS_PID" || fail "listing failed while files were written" +wait "$WR_PID" || fail "writing files failed while listing" + +ltfs_finish +echo "PASS" From ac847590d7cc903cf699e84acb45e2ca343a694a Mon Sep 17 00:00:00 2001 From: Matej Kenda Date: Tue, 13 Oct 2026 14:03:39 +0200 Subject: [PATCH 7/7] tests: document the test suite and run CI with FUSE 3 and FUSE 2 --- .github/workflows/test.yml | 8 ++++++-- README.md | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 54a5630e..a5a4700f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,10 @@ jobs: name: Build and test runs-on: ubuntu-latest timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + fuse: [fuse3, fuse2] steps: - name: Set up Git repository @@ -20,12 +24,12 @@ jobs: 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 + libfuse-dev libfuse3-dev attr - name: Build run: | ./autogen.sh - ./configure + ./configure ${{ matrix.fuse == 'fuse2' && '--with-fuse2' || '' }} make -j"$(nproc)" - name: Run tests diff --git a/README.md b/README.md index ca8ef1f9..ae6a4879 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,12 @@ make install | 8.0 | i386 | OK | | 7.2 | amd64 | OK | +## Running the test suite + +`make check` runs the integration tests. They mount LTFS through FUSE on a tape that the file backend emulates in a local directory, so no tape hardware is needed. + +The tests need Linux with `/dev/fuse` and are skipped on other systems. On macOS, `tests/run-in-docker.sh [configure-options...]` builds LTFS and runs the tests in an Ubuntu container. + ## Contributing Please read [CONTRIBUTING.md](.github/CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us.