Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions .github/workflows/build-macos.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: macos build

on:
push:
branches: [master]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

env:
MACFUSE_VERSION: "5.4.0"
MACFUSE_SHA256: 861814f0ac7fa8f6547ea40cdd49a36ac84bcc7d34f38a1fa74e8cf68b0401c5
HOMEBREW_NO_AUTO_UPDATE: "1"
HOMEBREW_NO_INSTALL_CLEANUP: "1"
HOMEBREW_NO_ENV_HINTS: "1"
HOMEBREW_NO_ANALYTICS: "1"

jobs:
build:
name: ${{ matrix.runner }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
runner: [macos-15, macos-26]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'

- name: Install macFUSE
timeout-minutes: 5
run: |
set -euxo pipefail
dmg="$RUNNER_TEMP/macfuse.dmg"
curl -fsSL --retry 3 --retry-all-errors --connect-timeout 20 --max-time 300 -o "$dmg" "https://github.com/macfuse/macfuse/releases/download/macfuse-${MACFUSE_VERSION}/macfuse-${MACFUSE_VERSION}.dmg"
echo "${MACFUSE_SHA256} $dmg" | shasum -a 256 -c -
hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$RUNNER_TEMP/macfuse"
sudo installer -pkg "$RUNNER_TEMP/macfuse/Extras/macFUSE ${MACFUSE_VERSION}.pkg" -target /
hdiutil detach "$RUNNER_TEMP/macfuse"
ls -l /usr/local/lib/pkgconfig/fuse3.pc /usr/local/lib/libfuse3.dylib
cat /usr/local/lib/pkgconfig/fuse3.pc

- name: Install build dependencies
timeout-minutes: 5
run: |
brew install glib
pip3 install meson==1.12.1 docutils==0.23 pytest==9.1.1 pytest-timeout==2.4.0

- name: Print tool versions
run: |
sw_vers
uname -m
clang --version
meson --version
ninja --version
pkg-config --version

- name: Build
env:
# macFUSE installs its pkg-config files here; Homebrew's pkg-config
# does not look in /usr/local on Apple Silicon.
PKG_CONFIG_PATH: /usr/local/lib/pkgconfig
run: |
meson setup build
ninja -C build

- name: Run unit tests
run: |
set -o pipefail
meson test -C build --suite unit --print-errorlogs -v | tee "$RUNNER_TEMP/unit-tests.log"
# meson exits 0 when no test matches the suite, so insist on a pass
grep -Eiq '^ok: +[1-9]' "$RUNNER_TEMP/unit-tests.log"

- name: Run mount-free tests
timeout-minutes: 2
env:
TEST_WITH_MACFUSE: "1"
run: |
set -o pipefail
cd build
python3 -m pytest test/test_hostname_validation.py test/test_macos_daemonize.py test/test_startup.py --timeout=60 | tee "$RUNNER_TEMP/pytest.log"
# exactly the expected tests, all passed: no skip, xfail or rename slips by
grep -Eq '^=+ 7 passed in ' "$RUNNER_TEMP/pytest.log"

- name: Smoke test
timeout-minutes: 2
run: |
set -o pipefail
./build/sshfs --version | tee version.txt
grep -q '^SSHFS version ' version.txt
grep -q '^FUSE library version ' version.txt
test -s build/sshfs.1
otool -L build/sshfs

- name: Upload build logs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: build-logs-${{ matrix.runner }}
path: build/meson-logs/
8 changes: 8 additions & 0 deletions .github/workflows/build-platforms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ jobs:
meson setup build ${{ matrix.buildtype && format('--buildtype={0}', matrix.buildtype) || '' }}
ninja -C build

- name: Run unit tests
if: matrix.run_tests
run: |
set -o pipefail
meson test -C build --suite unit --print-errorlogs -v | tee "$RUNNER_TEMP/unit-tests.log"
# meson exits 0 when no test matches the suite, so insist on a pass
grep -Eiq '^ok: +[1-9]' "$RUNNER_TEMP/unit-tests.log"

- name: Setup SSH
if: matrix.run_tests
run: |
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/build-ubuntu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ jobs:
path: build/sshfs
if-no-files-found: ignore

- name: Run unit tests
run: |
set -o pipefail
meson test -C build --suite unit --print-errorlogs -v | tee "$RUNNER_TEMP/unit-tests.log"
# meson exits 0 when no test matches the suite, so insist on a pass
grep -Eiq '^ok: +[1-9]' "$RUNNER_TEMP/unit-tests.log"

- name: Run tests
timeout-minutes: 20
run: |
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/sanitizers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ jobs:
ASAN_SYMBOLIZER_PATH: /usr/bin/llvm-symbolizer
run: |
mkdir -p "$RUNNER_TEMP/sanitizer"
set -o pipefail
meson test -C build --suite unit --print-errorlogs -v | tee "$RUNNER_TEMP/unit-tests.log"
# meson exits 0 when no test matches the suite, so insist on a pass
grep -Eiq '^ok: +[1-9]' "$RUNNER_TEMP/unit-tests.log"
cd build
python3 -m pytest test/ --timeout=180 --maxfail=99 --junitxml=test-results.xml
timeout-minutes: 20
Expand Down
22 changes: 22 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
import os
import sys
import pytest
import time
import re

from util import require_macfuse_lazy_mount

# These need macFUSE 5.3.3+, whose mount is delayed so that nothing is mounted
# on a runner. On macOS they are only collected with TEST_WITH_MACFUSE=1;
# test_startup.py also runs elsewhere, against a FUSE mount.
MACFUSE_TESTS = ("test_macos_daemonize.py", "test_startup.py")

if sys.platform != "darwin":
collect_ignore = ["test_macos_daemonize.py"]
elif os.environ.get("TEST_WITH_MACFUSE") != "1":
collect_ignore = list(MACFUSE_TESTS)


@pytest.fixture(scope="module", autouse=True)
def macfuse_prerequisite(request):
# collect_ignore does not apply to files named on the command line, so
# they skip here without the opt-in
if sys.platform == "darwin" and os.path.basename(request.module.__file__) in MACFUSE_TESTS:
require_macfuse_lazy_mount()


# If a test fails, wait a moment before retrieving the captured stdout/stderr.
# When using a server process, this makes sure that we capture any potential
# output of the server that comes *after* a test has failed. For example, if a
Expand Down
102 changes: 102 additions & 0 deletions test/daemonize_shim.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* DYLD_INSERT_LIBRARIES shim for test_macos_daemonize.py.
*
* macFUSE 5.3.3+ delays the real mount until the session is first used and
* remembers that in a process-wide flag. Once it is set, fuse_daemonize()
* refuses to fork. Replace fuse_daemonize() with a check of that flag, so a
* test can run sshfs without -f and see whether anything started the mount
* too early. No working mount is needed: a hosted runner can't finish one,
* and the shim exits before the real fuse_daemonize() would fork. It also
* stops fuse_session_fd(), the call that used to start the mount too early,
* before it can try a real mount.
*
* Exit codes: 0 ok, 86 mount started before fuse_daemonize(), 87 fuse_daemonize()
* called with foreground set, 88 fuse_daemonize() never reached, 89 watchdog
* not installed.
*/

#include <signal.h>
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

/* not public API: the test fails to build the shim if libfuse3 lacks it */
extern bool fuse_darwin_mount_started(void);
extern int fuse_daemonize(int foreground);
struct fuse_session;
extern int fuse_session_fd(struct fuse_session *se);

#define WATCHDOG_SECS 25

/* dyld-interposing.h is not in the public SDK */
#define DYLD_INTERPOSE(replacement, replacee) \
__attribute__((used)) static struct { const void *new; const void *old; } \
interpose_##replacee __attribute__((section("__DATA,__interpose"))) = \
{ (const void *) &replacement, (const void *) &replacee };

static void say(const char *msg)
{
(void) write(2, msg, strlen(msg));
}

/*
* If something starts the mount before fuse_daemonize() the call blocks,
* since the runner can't finish a mount. Look at the same flag from a timer
* so that still ends as a clear failure and not as a hang. A timer and not a
* thread: macFUSE warns when a threaded process forks, and sshfs forks ssh.
*/
static void watchdog(int sig)
{
(void) sig;
if (fuse_darwin_mount_started()) {
say("daemonize-shim: VIOLATION: mount started before fuse_daemonize (blocked)\n");
_exit(86);
}
say("daemonize-shim: TIMEOUT: fuse_daemonize not reached, mount not started\n");
_exit(88);
}

static int shim_daemonize(int foreground)
{
if (fuse_darwin_mount_started()) {
say("daemonize-shim: VIOLATION: mount started before fuse_daemonize\n");
_exit(86);
}
if (foreground) {
say("daemonize-shim: fuse_daemonize(foreground=1), fork path not exercised\n");
_exit(87);
}
say("daemonize-shim: ok: fuse_daemonize(foreground=0) before mount start\n");
_exit(0);
}

DYLD_INTERPOSE(shim_daemonize, fuse_daemonize)

/* shim_daemonize() never returns, so any call here is too early */
static int shim_session_fd(struct fuse_session *se)
{
(void) se;
say("daemonize-shim: VIOLATION: fuse_session_fd called before fuse_daemonize\n");
_exit(86);
}

DYLD_INTERPOSE(shim_session_fd, fuse_session_fd)

__attribute__((constructor))
static void shim_init(void)
{
struct sigaction sa;

/* don't hand the shim on to ssh or the fake server */
unsetenv("DYLD_INSERT_LIBRARIES");
say("daemonize-shim: loaded\n");

memset(&sa, 0, sizeof(sa));
sa.sa_handler = watchdog;
if (sigaction(SIGALRM, &sa, NULL) != 0) {
say("daemonize-shim: cannot install the watchdog\n");
_exit(89);
}
alarm(WATCHDOG_SECS);
}
16 changes: 15 additions & 1 deletion test/meson.build
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
test_scripts = [ 'conftest.py', 'pytest.ini', 'test_sshfs.py',
'test_hostname_validation.py', 'util.py' ]
'test_hostname_validation.py', 'test_startup.py', 'util.py' ]
if host_machine.system() == 'darwin'
test_scripts += [ 'test_macos_daemonize.py', 'daemonize_shim.c' ]
endif
custom_target('test_scripts', input: test_scripts,
output: test_scripts, build_by_default: true,
command: ['cp', '-fPp',
'@INPUT@', meson.current_build_dir() ])

# Unit tests for cache.c. The test #includes cache.c itself, so it is built
# from its own source only. They need no mount and are in the 'unit' suite.
# Run them with 'meson test --suite unit': the wrong_cmd test below always
# fails, so a plain 'meson test' does too.
test_cache = executable('test_cache', 'test_cache.c',
include_directories: include_dirs,
dependencies: sshfs_deps,
c_args: ['-DFUSE_USE_VERSION=31'],
install: false)
test('cache', test_cache, suite: 'unit')

# Provide something helpful when running 'ninja test'
wrong_cmd = executable('wrong_command', 'wrong_command.c',
install: false)
Expand Down
Loading
Loading