diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 4c4047b8e0..a4969dd8c4 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -48,6 +48,12 @@ jobs: ${{ matrix.package }}/go.mod ${{ matrix.package }}/go.sum + - name: Setup envd tests + run: | + # Install bindfs for FUSE mount tests + sudo apt-get update && sudo apt-get install -y bindfs + if: matrix.package == 'packages/envd' + - name: Setup orchestrator tests run: | # Enable unprivileged uffd mode diff --git a/packages/envd/internal/services/filesystem/utils.go b/packages/envd/internal/services/filesystem/utils.go index 9a75826241..4e66b228a1 100644 --- a/packages/envd/internal/services/filesystem/utils.go +++ b/packages/envd/internal/services/filesystem/utils.go @@ -12,6 +12,31 @@ import ( rpc "github.com/e2b-dev/infra/packages/envd/internal/services/spec/filesystem" ) +// Filesystem magic numbers from Linux kernel (include/uapi/linux/magic.h) +const ( + nfsSuperMagic = 0x6969 + cifsMagic = 0xFF534D42 + smbSuperMagic = 0x517B + smb2MagicNumber = 0xFE534D42 + fuseSuperMagic = 0x65735546 +) + +// IsPathOnNetworkMount checks if the given path is on a network filesystem mount. +// Returns true if the path is on NFS, CIFS, SMB, or FUSE filesystem. +func IsPathOnNetworkMount(path string) (bool, error) { + var statfs syscall.Statfs_t + if err := syscall.Statfs(path, &statfs); err != nil { + return false, fmt.Errorf("failed to statfs %s: %w", path, err) + } + + switch statfs.Type { + case nfsSuperMagic, cifsMagic, smbSuperMagic, smb2MagicNumber, fuseSuperMagic: + return true, nil + default: + return false, nil + } +} + // getEntryType determines the type of file entry based on its mode and path. // If the file is a symlink, it follows the symlink to determine the actual type. func getEntryType(mode os.FileMode) rpc.FileType { diff --git a/packages/envd/internal/services/filesystem/utils_test.go b/packages/envd/internal/services/filesystem/utils_test.go index 8eb38f7cae..b724fdd2c5 100644 --- a/packages/envd/internal/services/filesystem/utils_test.go +++ b/packages/envd/internal/services/filesystem/utils_test.go @@ -1,7 +1,9 @@ package filesystem import ( + "context" "os" + "os/exec" "os/user" "path/filepath" "testing" @@ -13,6 +15,52 @@ import ( rpc "github.com/e2b-dev/infra/packages/envd/internal/services/spec/filesystem" ) +func TestIsPathOnNetworkMount(t *testing.T) { + t.Parallel() + + // Test with a regular directory (should not be on network mount) + tempDir := t.TempDir() + isNetwork, err := IsPathOnNetworkMount(tempDir) + require.NoError(t, err) + assert.False(t, isNetwork, "temp directory should not be on a network mount") +} + +func TestIsPathOnNetworkMount_FuseMount(t *testing.T) { + t.Parallel() + + // Require bindfs to be available + _, err := exec.LookPath("bindfs") + require.NoError(t, err, "bindfs must be installed for this test") + + // Require fusermount to be available (needed for unmounting) + _, err = exec.LookPath("fusermount") + require.NoError(t, err, "fusermount must be installed for this test") + + // Create source and mount directories + sourceDir := t.TempDir() + mountDir := t.TempDir() + + // Mount sourceDir onto mountDir using bindfs (FUSE) + ctx := context.Background() + cmd := exec.CommandContext(ctx, "bindfs", sourceDir, mountDir) + require.NoError(t, cmd.Run(), "failed to mount bindfs") + + // Ensure we unmount on cleanup + t.Cleanup(func() { + _ = exec.CommandContext(context.Background(), "fusermount", "-u", mountDir).Run() + }) + + // Test that the FUSE mount is detected + isNetwork, err := IsPathOnNetworkMount(mountDir) + require.NoError(t, err) + assert.True(t, isNetwork, "FUSE mount should be detected as network filesystem") + + // Test that the source directory is NOT detected as network mount + isNetworkSource, err := IsPathOnNetworkMount(sourceDir) + require.NoError(t, err) + assert.False(t, isNetworkSource, "source directory should not be detected as network filesystem") +} + func TestGetEntryType(t *testing.T) { t.Parallel() diff --git a/packages/envd/internal/services/filesystem/watch.go b/packages/envd/internal/services/filesystem/watch.go index 68a9879aa8..33d866e3e0 100644 --- a/packages/envd/internal/services/filesystem/watch.go +++ b/packages/envd/internal/services/filesystem/watch.go @@ -43,6 +43,15 @@ func (s Service) watchHandler(ctx context.Context, req *connect.Request[rpc.Watc return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path %s not a directory: %w", watchPath, err)) } + // Check if path is on a network filesystem mount + isNetworkMount, err := IsPathOnNetworkMount(watchPath) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error checking mount status: %w", err)) + } + if isNetworkMount { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot watch path on network filesystem: %s", watchPath)) + } + w, err := fsnotify.NewWatcher() if err != nil { return connect.NewError(connect.CodeInternal, fmt.Errorf("error creating watcher: %w", err)) diff --git a/packages/envd/internal/services/filesystem/watch_sync.go b/packages/envd/internal/services/filesystem/watch_sync.go index 35610f6dee..400994c421 100644 --- a/packages/envd/internal/services/filesystem/watch_sync.go +++ b/packages/envd/internal/services/filesystem/watch_sync.go @@ -164,6 +164,15 @@ func (s Service) CreateWatcher(ctx context.Context, req *connect.Request[rpc.Cre return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path %s not a directory: %w", watchPath, err)) } + // Check if path is on a network filesystem mount + isNetworkMount, err := IsPathOnNetworkMount(watchPath) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error checking mount status: %w", err)) + } + if isNetworkMount { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot watch path on network filesystem: %s", watchPath)) + } + watcherId := "w" + id.Generate() w, err := CreateFileWatcher(ctx, watchPath, req.Msg.GetRecursive(), watcherId, s.logger)