Commit 22af5e39 authored by Ramon Nou's avatar Ramon Nou
Browse files

test(client): cover CAS and spawn paths and fix symlinkat bypass

parent cb910c1f
Loading
Loading
Loading
Loading
Loading
+10 −4
Changes for src/client/intercept.cpp: 10 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -125,7 +125,8 @@ is_mpi_runtime_path(const char* path) {
}

const char*
first_path_arg(long syscall_number, long arg0, long arg1, long arg3) {
first_path_arg(long syscall_number, long arg0, long arg1, long arg2,
               long arg3) {
    switch(syscall_number) {
#ifdef SYS_open
        case SYS_open:
@@ -177,7 +178,6 @@ first_path_arg(long syscall_number, long arg0, long arg1, long arg3) {
        case SYS_statx:
#endif
        case SYS_unlinkat:
        case SYS_symlinkat:
        case SYS_faccessat:
#ifdef SYS_faccessat2
        case SYS_faccessat2:
@@ -195,6 +195,10 @@ first_path_arg(long syscall_number, long arg0, long arg1, long arg3) {
#endif
            return reinterpret_cast<const char*>(arg1);

        case SYS_symlinkat:
            // oldname is literal link content; classify the destination path.
            return reinterpret_cast<const char*>(arg2);

#ifdef SYS_rename
        case SYS_rename:
            return reinterpret_cast<const char*>(arg0);
@@ -381,12 +385,14 @@ should_bypass_syscall(long syscall_number, long arg0, long arg1, long arg2,
        return true;
    }

    const char* path1 = first_path_arg(syscall_number, arg0, arg1, arg3);
    const char* path1 = first_path_arg(syscall_number, arg0, arg1, arg2, arg3);
    const char* path2 = second_path_arg(syscall_number, arg1, arg3);

    // Fast path for pathname syscalls
    if(path1 != nullptr) {
        int dirfd1 = first_dirfd_arg(syscall_number, arg0);
        int dirfd1 = syscall_number == SYS_symlinkat
                             ? static_cast<int>(arg1)
                             : first_dirfd_arg(syscall_number, arg0);
        if(!is_definitely_external_path(dirfd1, path1)) {
            return false;
        }
+60 −0
Changes for tests/integration/rename/test_spawn_dup_actions.py: 60 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,8 @@

import sys

import pytest


def test_spawn_dup_actions(gkfs_daemon, gkfs_shell, tmp_path):
    output = tmp_path / "spawn-output"
@@ -27,3 +29,61 @@ finally:
"""
    result = gkfs_shell.run(sys.executable, "-c", code)
    assert result.exit_code == 0, result.stderr.decode()


@pytest.mark.parametrize("shell_fixture", ["gkfs_shell", "gkfs_shellLibc"])
def test_spawn_open_close_and_attributes(shell_fixture, request, gkfs_daemon,
                                         tmp_path):
    """Virtual-cwd spawning preserves actions, attributes and POSIX errors."""
    shell = request.getfixturevalue(shell_fixture)
    output = tmp_path / "spawn-actions-output"
    command = shell.run("env", "LIBGKFS_LOG=none", sys.executable, "-c", """
import errno
import fcntl
import os
import signal
import sys

mount, output = sys.argv[1:]
os.chdir(mount)
source = os.open('/dev/null', os.O_RDONLY)
# Avoid a low fd being reused by the child's interpreter/preload startup.
fd = fcntl.fcntl(source, fcntl.F_DUPFD, 500)
os.close(source)
child = '''
import errno, os, signal, sys
try:
    os.fstat(int(sys.argv[1]))
except OSError as error:
    assert error.errno == errno.EBADF
else:
    raise AssertionError('close action was not applied')
assert os.getpgrp() == os.getpid()
assert signal.SIGUSR1 in signal.pthread_sigmask(signal.SIG_BLOCK, [])
os.write(1, b'actions-and-attributes')
'''
actions = [(os.POSIX_SPAWN_OPEN, 1, output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600),
           (os.POSIX_SPAWN_CLOSE, fd)]
try:
    pid = os.posix_spawn(sys.executable, [sys.executable, '-c', child, str(fd)],
                         os.environ, file_actions=actions, setpgroup=0,
                         setsigmask=(signal.SIGUSR1,), setsigdef=(signal.SIGUSR2,))
    assert os.waitpid(pid, 0)[1] == 0
    for executable, file_actions, expected in (
        ('/does-not-exist-gkfs-spawn', [], errno.ENOENT),
        (sys.executable, [(os.POSIX_SPAWN_OPEN, 1, output + '/missing', os.O_RDONLY, 0)], errno.ENOTDIR),
        (sys.executable, [(os.POSIX_SPAWN_DUP2, 999999, 1)], errno.EBADF),
    ):
        try:
            os.posix_spawn(executable, [executable], os.environ, file_actions=file_actions)
        except OSError as error:
            assert error.errno == expected, (executable, error)
        else:
            raise AssertionError('spawn did not report setup failure')
finally:
    os.close(fd)
print('PASS spawn actions attributes and errors')
""", str(gkfs_daemon.mountdir), str(output), timeout=60)
    assert command.exit_code == 0, command.stderr.decode(errors="replace")
    assert b"PASS spawn actions attributes and errors" in command.stdout
    assert output.read_bytes() == b"actions-and-attributes"
+50 −0
Changes for tests/integration/syscalls/test_interceptor_runtime_bypass.py: 50 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -61,3 +61,53 @@ def test_preload_bypasses_mpi_runtime_paths(gkfs_daemon, gkfs_shell):

    log = client_log.read_text(errors="replace") if client_log.exists() else ""
    assert str(runtime_file) not in log


@pytest.mark.parametrize("shell_fixture", ["gkfs_shell", "gkfs_shellLibc"])
def test_external_dirfd_and_internal_paths(shell_fixture, request, gkfs_daemon,
                                          tmp_path):
    """External *at calls bypass safely without hiding virtual descriptors."""
    import sys

    shell = request.getfixturevalue(shell_fixture)
    external = tmp_path / "external-at"
    external.mkdir()
    command = shell.run("env", "LIBGKFS_LOG=none", sys.executable, "-c", """
import os
import sys

external, mount = sys.argv[1:]
directory = os.open(external, os.O_RDONLY | os.O_DIRECTORY)
try:
    fd = os.open('source', os.O_CREAT | os.O_RDWR, 0o600, dir_fd=directory)
    os.write(fd, b'external')
    os.close(fd)
    assert os.stat('source', dir_fd=directory).st_size == 8
    os.rename('source', 'target', src_dir_fd=directory, dst_dir_fd=directory)
    os.symlink('target', 'link', dir_fd=directory)
    assert os.readlink('link', dir_fd=directory) == 'target'
    os.unlink('link', dir_fd=directory)
    os.unlink('target', dir_fd=directory)
    os.mkdir('child', dir_fd=directory)
    os.rmdir('child', dir_fd=directory)
    os.chdir(mount)
    fd = os.open('internal', os.O_CREAT | os.O_RDWR, 0o600)
    os.write(fd, b'internal')
    assert os.fstat(fd).st_size == 8
    os.close(fd)
    assert os.stat('internal').st_size == 8
    os.unlink('internal')
    fd = os.open('after-chdir', os.O_CREAT | os.O_RDWR, 0o600, dir_fd=directory)
    os.close(fd)
    os.unlink('after-chdir', dir_fd=directory)
    # Link content is literal, not a path relative to the virtual cwd.
    os.symlink('../literal-target', 'after-chdir-link', dir_fd=directory)
    assert os.readlink('after-chdir-link', dir_fd=directory) == '../literal-target'
    os.unlink('after-chdir-link', dir_fd=directory)
finally:
    os.close(directory)
print('PASS external dirfd and virtual cwd')
""", str(external), str(gkfs_daemon.mountdir), timeout=60)
    assert command.exit_code == 0, command.stderr.decode(errors="replace")
    assert b"PASS external dirfd and virtual cwd" in command.stdout
    assert list(external.iterdir()) == []
+2 −0
Changes for tests/unit/CMakeLists.txt: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -102,6 +102,7 @@ if (GKFS_TESTS_GUIDED_DISTRIBUTION)
endif ()

if (GKFS_BUILD_USER_LIB)
    target_sources(unit_tests PRIVATE ${CMAKE_CURRENT_LIST_DIR}/test_shm_cas.cpp)
target_link_libraries(unit_tests
    PRIVATE
    Catch2WithMain
@@ -114,6 +115,7 @@ target_link_libraries(unit_tests
    distributor
    statistics
    gkfs_user_lib
    gkfs_common
)
else ()
target_link_libraries(unit_tests
+162 −0
Changes for tests/unit/test_shm_cas.cpp: 162 added lines, 0 removed lines.
Original line number Diff line number Diff line
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright 2026, Barcelona Supercomputing Center (BSC), Spain

#include <client/shm_cas.hpp>
#include <config.hpp>
#include "helpers/helpers.hpp"
#include <catch2/catch_all.hpp>
#include <fmt/format.h>
#include <filesystem>
#include <fstream>
#include <cerrno>
#include <functional>
#include <unistd.h>

namespace {
struct CasNamespace {
    uint64_t id;
    std::string path;
    gkfs::shm::ShmCasManager cache;

    CasNamespace() {
        // Reserve an unused namespace atomically, without touching another
        // test.
        auto pattern =
                fmt::format("/dev/shm/gkfs-cas-test-{}-XXXXXX", ::getpid());
        const auto fd = ::mkstemp(pattern.data());
        REQUIRE(fd >= 0);
        // tmpfs user quotas can be exhausted even when statvfs shows free
        // space.
        const char probe = 0;
        const auto written = ::write(fd, &probe, 1);
        const auto error = errno;
        ::close(fd);
        reservation = pattern;
        if(written != 1 && (error == ENOSPC || error == EDQUOT)) {
            ::unlink(reservation.c_str());
            SKIP("No writable shared-memory quota available for CAS tests");
        }
        REQUIRE(written == 1);
        id = std::hash<std::string>{}(pattern);
        path = fmt::format("/dev/shm/gkfs_cas_ns_{}_{}", ::getuid(), id);
        REQUIRE_FALSE(std::filesystem::exists(path));
    }

    ~CasNamespace() {
        cache.destroy();
        ::unlink(path.c_str());
        ::unlink(reservation.c_str());
    }

    std::string reservation;
};

struct Value {
    mode_t mode{};
    size_t size{};
    time_t mtime{};
    uint64_t high{}, low{};
    std::string data;

    bool
    get(gkfs::shm::ShmCasManager& cache, const std::string& path) {
        return cache.get(path, mode, size, mtime, high, low, data);
    }
};
} // namespace

TEST_CASE("CAS cache rejects unavailable and invalid keys", "[shm_cas]") {
    CasNamespace ns;
    Value value;
    char buffer[8]{};
    REQUIRE_FALSE(ns.cache.initialized());
    REQUIRE_FALSE(ns.cache.put("/file", 0600, 1, 2, 3, 4, "x"));
    REQUIRE_FALSE(value.get(ns.cache, "/file"));
    REQUIRE_FALSE(ns.cache.invalidate("/file"));
    REQUIRE(ns.cache.read_chunk("", "/file", buffer, sizeof(buffer), 0) == -1);
    REQUIRE(ns.cache.init(ns.id));
    REQUIRE(ns.cache.init(ns.id));
    for(const auto& key :
        {std::string{}, std::string(gkfs::shm::SHM_CAS_MAX_PATH, 'x')}) {
        REQUIRE_FALSE(ns.cache.put(key, 0600, 1, 2, 3, 4, "x"));
        REQUIRE_FALSE(value.get(ns.cache, key));
        REQUIRE_FALSE(ns.cache.invalidate(key));
    }
    REQUIRE_FALSE(value.get(ns.cache, "/missing"));
    REQUIRE(ns.cache.invalidate("/missing"));
    REQUIRE(ns.cache.read_chunk("", "/missing", buffer, sizeof(buffer), 0) ==
            -1);
    ns.cache.destroy();
    ns.cache.destroy();
    REQUIRE_FALSE(ns.cache.initialized());
}

TEST_CASE("CAS inline entries can be updated shared and invalidated",
          "[shm_cas]") {
    CasNamespace ns;
    REQUIRE(ns.cache.init(ns.id));
    REQUIRE(ns.cache.put("/file", 0640, 7, 1234, 11, 22,
                         std::string("ab\0cdef", 7)));
    gkfs::shm::ShmCasManager peer;
    REQUIRE(peer.init(ns.id));
    Value value;
    REQUIRE(value.get(peer, "/file"));
    REQUIRE(value.mode == 0640);
    REQUIRE(value.size == 7);
    REQUIRE(value.mtime == 1234);
    REQUIRE(value.high == 11);
    REQUIRE(value.low == 22);
    REQUIRE(value.data == std::string("ab\0cdef", 7));
    char buffer[16]{};
    REQUIRE(peer.read_chunk("", "/file", buffer, sizeof(buffer), 2) == 5);
    REQUIRE(std::string(buffer, 5) == std::string("\0cdef", 5));
    REQUIRE(peer.read_chunk("", "/file", buffer, 2, 0) == 2);
    REQUIRE(std::string(buffer, 2) == "ab");
    REQUIRE(peer.read_chunk("", "/file", buffer, sizeof(buffer), -1) == -1);
    REQUIRE(ns.cache.put("/file", 0600, 0, 4321, 0, 0, ""));
    REQUIRE(value.get(peer, "/file"));
    REQUIRE(value.data.empty());
    REQUIRE(value.mode == 0600);
    REQUIRE(value.size == 0);
    REQUIRE(value.mtime == 4321);
    REQUIRE(value.high == 0);
    REQUIRE(value.low == 0);
    const std::string large(gkfs::shm::SHM_CAS_MAX_INLINE + 10, 'z');
    REQUIRE(ns.cache.put("/large", 0600, large.size(), 1, 0, 0, large));
    REQUIRE(value.get(peer, "/large"));
    REQUIRE(value.data == large.substr(0, gkfs::shm::SHM_CAS_MAX_INLINE));
    REQUIRE(value.size == large.size());
    REQUIRE(peer.invalidate("/file"));
    REQUIRE_FALSE(value.get(ns.cache, "/file"));
    REQUIRE(value.get(ns.cache, "/large"));
    peer.destroy();
    REQUIRE(peer.init(ns.id));
    REQUIRE(value.get(peer, "/large"));
}

TEST_CASE("CAS reads local chunks and reports unavailable storage",
          "[shm_cas]") {
    CasNamespace ns;
    REQUIRE(ns.cache.init(ns.id));
    helpers::temporary_directory root;
    const auto chunk = root.dirname() / gkfs::config::data::chunk_dir / ".cas" /
                       "00000000000000010000000000000002";
    std::filesystem::create_directories(chunk.parent_path());
    {
        std::ofstream output(chunk, std::ios::binary);
        output << "local-chunk";
        REQUIRE(output.good());
    }
    REQUIRE(ns.cache.put("/chunk", 0600, 11, 1, 1, 2, ""));
    char buffer[16]{};
    REQUIRE(ns.cache.read_chunk(root.dirname().string(), "/chunk", buffer, 5,
                                6) == 5);
    REQUIRE(std::string(buffer, 5) == "chunk");
    REQUIRE(ns.cache.read_chunk(root.dirname().string(), "/chunk", buffer, 5,
                                11) == 0);
    REQUIRE(ns.cache.read_chunk(root.dirname().string(), "/chunk", buffer, 5,
                                -1) == -1);
    std::filesystem::remove(chunk);
    REQUIRE(ns.cache.read_chunk(root.dirname().string(), "/chunk", buffer, 5,
                                0) == -1);
}
 No newline at end of file