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; } 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/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) 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"