serve smb: add new command to serve a remote over SMB

Add a new `rclone serve smb` command that serves any rclone remote
over the SMB protocol using the github.com/macos-fuse-t/go-smb2
server library.

Fixes #7596
This commit is contained in:
Nick Craig-Wood
2026-04-14 11:03:48 +01:00
parent 9a3bf28142
commit 39370cb6cc
10 changed files with 1334 additions and 0 deletions

View File

@@ -66,6 +66,7 @@ import (
_ "github.com/rclone/rclone/cmd/serve/restic"
_ "github.com/rclone/rclone/cmd/serve/s3"
_ "github.com/rclone/rclone/cmd/serve/sftp"
_ "github.com/rclone/rclone/cmd/serve/smb"
_ "github.com/rclone/rclone/cmd/serve/webdav"
_ "github.com/rclone/rclone/cmd/settier"
_ "github.com/rclone/rclone/cmd/sha1sum"

224
cmd/serve/smb/proxy.go Normal file
View File

@@ -0,0 +1,224 @@
//go:build !windows && !plan9
package smb
import (
"sync"
"syscall"
"github.com/macos-fuse-t/go-smb2/vfs"
"github.com/rclone/rclone/cmd/serve/proxy"
rclonefs "github.com/rclone/rclone/fs"
)
// proxySMBVFS wraps smbVFS with lazy initialization via the auth proxy.
//
// NTLM authentication doesn't provide plaintext passwords, so we
// can't do per-user auth proxy dispatch the way HTTP/SFTP can. Instead
// we allow guest access at the NTLM level and call the proxy once
// (with the configured user/pass) to obtain a VFS. All SMB sessions
// share that VFS.
type proxySMBVFS struct {
proxy *proxy.Proxy
user string
pass string
once sync.Once
vfs *smbVFS
err error
}
func newProxySMBVFS(p *proxy.Proxy, user, pass string) *proxySMBVFS {
return &proxySMBVFS{
proxy: p,
user: user,
pass: pass,
}
}
// init lazily creates the underlying smbVFS by calling the proxy
func (p *proxySMBVFS) init() (*smbVFS, error) {
p.once.Do(func() {
user := p.user
if user == "" {
user = "anonymous"
}
pass := p.pass
if pass == "" {
pass = "anonymous"
}
VFS, _, err := p.proxy.Call(user, pass, false)
if err != nil {
rclonefs.Errorf(nil, "serve smb: auth proxy call failed: %v", err)
p.err = err
return
}
p.vfs = newSMBVFS(VFS)
})
return p.vfs, p.err
}
// All VFSFileSystem methods delegate to the lazily-initialized smbVFS.
func (p *proxySMBVFS) GetAttr(h vfs.VfsHandle) (*vfs.Attributes, error) {
s, err := p.init()
if err != nil {
return nil, syscall.EIO
}
return s.GetAttr(h)
}
func (p *proxySMBVFS) SetAttr(h vfs.VfsHandle, a *vfs.Attributes) (*vfs.Attributes, error) {
s, err := p.init()
if err != nil {
return nil, syscall.EIO
}
return s.SetAttr(h, a)
}
func (p *proxySMBVFS) StatFS(h vfs.VfsHandle) (*vfs.FSAttributes, error) {
s, err := p.init()
if err != nil {
return nil, syscall.EIO
}
return s.StatFS(h)
}
func (p *proxySMBVFS) FSync(h vfs.VfsHandle) error {
s, err := p.init()
if err != nil {
return syscall.EIO
}
return s.FSync(h)
}
func (p *proxySMBVFS) Flush(h vfs.VfsHandle) error {
s, err := p.init()
if err != nil {
return syscall.EIO
}
return s.Flush(h)
}
func (p *proxySMBVFS) Open(name string, flags int, mode int) (vfs.VfsHandle, error) {
s, err := p.init()
if err != nil {
return 0, syscall.EIO
}
return s.Open(name, flags, mode)
}
func (p *proxySMBVFS) Close(h vfs.VfsHandle) error {
s, err := p.init()
if err != nil {
return syscall.EIO
}
return s.Close(h)
}
func (p *proxySMBVFS) Lookup(h vfs.VfsHandle, name string) (*vfs.Attributes, error) {
s, err := p.init()
if err != nil {
return nil, syscall.EIO
}
return s.Lookup(h, name)
}
func (p *proxySMBVFS) Mkdir(name string, mode int) (*vfs.Attributes, error) {
s, err := p.init()
if err != nil {
return nil, syscall.EIO
}
return s.Mkdir(name, mode)
}
func (p *proxySMBVFS) Read(h vfs.VfsHandle, buf []byte, offset uint64, flags int) (int, error) {
s, err := p.init()
if err != nil {
return 0, syscall.EIO
}
return s.Read(h, buf, offset, flags)
}
func (p *proxySMBVFS) Write(h vfs.VfsHandle, data []byte, offset uint64, flags int) (int, error) {
s, err := p.init()
if err != nil {
return 0, syscall.EIO
}
return s.Write(h, data, offset, flags)
}
func (p *proxySMBVFS) OpenDir(name string) (vfs.VfsHandle, error) {
s, err := p.init()
if err != nil {
return 0, syscall.EIO
}
return s.OpenDir(name)
}
func (p *proxySMBVFS) ReadDir(h vfs.VfsHandle, pos int, count int) ([]vfs.DirInfo, error) {
s, err := p.init()
if err != nil {
return nil, syscall.EIO
}
return s.ReadDir(h, pos, count)
}
func (p *proxySMBVFS) Readlink(h vfs.VfsHandle) (string, error) {
s, err := p.init()
if err != nil {
return "", syscall.EIO
}
return s.Readlink(h)
}
func (p *proxySMBVFS) Unlink(h vfs.VfsHandle) error {
s, err := p.init()
if err != nil {
return syscall.EIO
}
return s.Unlink(h)
}
func (p *proxySMBVFS) Truncate(h vfs.VfsHandle, size uint64) error {
s, err := p.init()
if err != nil {
return syscall.EIO
}
return s.Truncate(h, size)
}
func (p *proxySMBVFS) Rename(h vfs.VfsHandle, newPath string, flags int) error {
s, err := p.init()
if err != nil {
return syscall.EIO
}
return s.Rename(h, newPath, flags)
}
func (p *proxySMBVFS) Symlink(h vfs.VfsHandle, target string, flags int) (*vfs.Attributes, error) {
return nil, syscall.ENOSYS
}
func (p *proxySMBVFS) Link(from vfs.VfsNode, to vfs.VfsNode, name string) (*vfs.Attributes, error) {
return nil, syscall.ENOSYS
}
func (p *proxySMBVFS) Listxattr(h vfs.VfsHandle) ([]string, error) {
return nil, nil
}
func (p *proxySMBVFS) Getxattr(h vfs.VfsHandle, name string, buf []byte) (int, error) {
return 0, syscall.ENOENT
}
func (p *proxySMBVFS) Setxattr(h vfs.VfsHandle, name string, data []byte) error {
return syscall.ENOSYS
}
func (p *proxySMBVFS) Removexattr(h vfs.VfsHandle, name string) error {
return syscall.ENOSYS
}
// Check that proxySMBVFS implements VFSFileSystem
var _ vfs.VFSFileSystem = (*proxySMBVFS)(nil)

110
cmd/serve/smb/server.go Normal file
View File

@@ -0,0 +1,110 @@
//go:build !windows && !plan9
package smb
import (
"context"
"errors"
"fmt"
"net"
smbserver "github.com/macos-fuse-t/go-smb2/server"
smbvfs "github.com/macos-fuse-t/go-smb2/vfs"
"github.com/rclone/rclone/cmd/serve/proxy"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/vfs"
"github.com/rclone/rclone/vfs/vfscommon"
)
// server contains everything to run the SMB server
type server struct {
f fs.Fs
opt Options
vfs *vfs.VFS
ctx context.Context // for global config
proxy *proxy.Proxy
listener net.Listener
srv *smbserver.Server
stopped chan struct{} // for waiting on the server to stop
}
func newServer(ctx context.Context, f fs.Fs, opt *Options, vfsOpt *vfscommon.Options, proxyOpt *proxy.Options) (*server, error) {
s := &server{
f: f,
ctx: ctx,
opt: *opt,
stopped: make(chan struct{}),
}
if proxyOpt.AuthProxy != "" {
s.proxy = proxy.New(ctx, proxyOpt, vfsOpt)
} else {
s.vfs = vfs.New(ctx, f, vfsOpt)
}
if !s.opt.NoAuth && s.opt.User == "" && s.opt.Pass == "" && s.proxy == nil {
return nil, errors.New("no authorization found, use --user/--pass or --no-auth or --auth-proxy")
}
// Listen on the configured address
var err error
s.listener, err = net.Listen("tcp", s.opt.ListenAddr)
if err != nil {
return nil, fmt.Errorf("failed to listen: %w", err)
}
// Create the SMB VFS bridge.
// When using auth proxy, use a proxy-aware VFS that lazily calls
// the proxy to get a VFS on the first operation. NTLM doesn't
// provide plaintext passwords so we can't do per-user auth proxy
// in the same way as HTTP/SFTP. Instead we allow guest access
// at the NTLM level and let the proxy create the VFS.
var smbFS smbvfs.VFSFileSystem
if s.proxy != nil {
smbFS = newProxySMBVFS(s.proxy, s.opt.User, s.opt.Pass)
} else {
smbFS = newSMBVFS(s.vfs)
}
// Create shares map
shares := map[string]smbvfs.VFSFileSystem{
s.opt.ShareName: smbFS,
}
// Create NTLM authenticator
auth := &smbserver.NTLMAuthenticator{
UserPassword: map[string]string{},
AllowGuest: s.opt.NoAuth || s.proxy != nil,
}
if s.opt.User != "" && s.opt.Pass != "" {
auth.UserPassword[s.opt.User] = s.opt.Pass
}
// Configure the SMB server
cfg := &smbserver.ServerConfig{
AllowGuest: s.opt.NoAuth || s.proxy != nil,
}
s.srv = smbserver.NewServer(cfg, auth, shares)
return s, nil
}
// Serve starts the SMB server - blocks until Shutdown is called
func (s *server) Serve() error {
fs.Logf(nil, "SMB server listening on %v\n", s.listener.Addr())
err := s.srv.ServeListener(s.listener)
close(s.stopped)
return err
}
// Addr returns the address the server is listening on
func (s *server) Addr() net.Addr {
return s.listener.Addr()
}
// Shutdown stops the SMB server
func (s *server) Shutdown() error {
s.srv.Shutdown()
<-s.stopped
return nil
}

157
cmd/serve/smb/smb.go Normal file
View File

@@ -0,0 +1,157 @@
//go:build !windows && !plan9
// Package smb implements an SMB server to serve an rclone VFS
package smb
import (
"context"
"fmt"
"strings"
"github.com/rclone/rclone/cmd"
"github.com/rclone/rclone/cmd/serve"
"github.com/rclone/rclone/cmd/serve/proxy"
"github.com/rclone/rclone/cmd/serve/proxy/proxyflags"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config/configstruct"
"github.com/rclone/rclone/fs/config/flags"
"github.com/rclone/rclone/fs/rc"
"github.com/rclone/rclone/lib/systemd"
"github.com/rclone/rclone/vfs"
"github.com/rclone/rclone/vfs/vfscommon"
"github.com/rclone/rclone/vfs/vfsflags"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// OptionsInfo describes the Options in use
var OptionsInfo = fs.Options{{
Name: "addr",
Default: "localhost:445",
Help: "IPaddress:Port or :Port to bind server to",
}, {
Name: "user",
Default: "",
Help: "User name for authentication",
}, {
Name: "pass",
Default: "",
Help: "Password for authentication",
}, {
Name: "no_auth",
Default: false,
Help: "Allow connections with no authentication if set",
}, {
Name: "share_name",
Default: "rclone",
Help: "Name of the SMB share",
}}
// Options contains options for the SMB server
type Options struct {
ListenAddr string `config:"addr"` // Port to listen on
User string `config:"user"` // single username
Pass string `config:"pass"` // password for user
NoAuth bool `config:"no_auth"` // allow no authentication on connections
ShareName string `config:"share_name"` // name of the SMB share
}
func init() {
fs.RegisterGlobalOptions(fs.OptionsInfo{Name: "smb-serve", Opt: &Opt, Options: OptionsInfo})
}
// Opt is options set by command line flags
var Opt Options
// AddFlags adds flags for the SMB server
func AddFlags(flagSet *pflag.FlagSet, Opt *Options) {
flags.AddFlagsFromOptions(flagSet, "", OptionsInfo)
}
func init() {
vfsflags.AddFlags(Command.Flags())
proxyflags.AddFlags(Command.Flags())
AddFlags(Command.Flags(), &Opt)
serve.Command.AddCommand(Command)
serve.AddRc("smb", func(ctx context.Context, f fs.Fs, in rc.Params) (serve.Handle, error) {
// Read VFS Opts
var vfsOpt = vfscommon.Opt // set default opts
err := configstruct.SetAny(in, &vfsOpt)
if err != nil {
return nil, err
}
// Read Proxy Opts
var proxyOpt = proxy.Opt // set default opts
err = configstruct.SetAny(in, &proxyOpt)
if err != nil {
return nil, err
}
// Read opts
var opt = Opt // set default opts
err = configstruct.SetAny(in, &opt)
if err != nil {
return nil, err
}
// Create server
return newServer(ctx, f, &opt, &vfsOpt, &proxyOpt)
})
}
// Command definition for cobra
var Command = &cobra.Command{
Use: "smb remote:path",
Short: `Serve the remote over SMB.`,
Long: `Run an SMB server to serve a remote over SMB. This can be used
with an SMB client or you can make a remote of type [smb](/smb) to use with it.
You can use the [filter](/filtering) flags (e.g. ` + "`--include`, `--exclude`" + `)
to control what is served.
The server will log errors. Use ` + "`-v`" + ` to see access logs.
` + "`--bwlimit`" + ` will be respected for file transfers.
Use ` + "`--stats`" + ` to control the stats printing.
You must provide some means of authentication, either with
` + "`--user`/`--pass`" + `, an ` + "`--auth-proxy`" + `, or set the ` + "`--no-auth`" + ` flag for no
authentication when logging in.
By default the server binds to localhost:445 - if you want it to be
reachable externally then supply ` + "`--addr :445`" + ` for example.
Note that port 445 typically requires root/administrator privileges.
Use ` + "`--addr :1445`" + ` or similar to use a non-privileged port.
The remote will be served as a single SMB share. The share name
defaults to "rclone" and can be changed with ` + "`--share-name`" + `.
Note that the default of ` + "`--vfs-cache-mode off`" + ` is fine for the rclone
smb backend, but it may not be with other SMB clients.
This command uses the library https://github.com/macos-fuse-t/go-smb2
which the author has given special consent for rclone to build and
distribute under the rclone licensing terms.
` + strings.TrimSpace(vfs.Help()+proxy.Help),
Annotations: map[string]string{
"versionIntroduced": "v1.74",
"groups": "Filter",
},
Run: func(command *cobra.Command, args []string) {
var f fs.Fs
if proxy.Opt.AuthProxy == "" {
cmd.CheckArgs(1, 1, command, args)
f = cmd.NewFsSrc(args)
} else {
cmd.CheckArgs(0, 0, command, args)
}
cmd.Run(false, true, command, func() error {
s, err := newServer(context.Background(), f, &Opt, &vfscommon.Opt, &proxy.Opt)
if err != nil {
fs.Fatal(nil, fmt.Sprint(err))
}
defer systemd.Notify()()
return s.Serve()
})
},
}

View File

@@ -0,0 +1,89 @@
//go:build !windows && !darwin && !plan9
package smb
import (
"context"
"os"
"testing"
"time"
smb2client "github.com/cloudsoda/go-smb2"
_ "github.com/rclone/rclone/backend/local"
"github.com/rclone/rclone/cmd/serve/proxy"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/vfs/vfscommon"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSMBManualNoAuth(t *testing.T) {
testSMBManual(t, "", "", true)
}
func TestSMBManualAuth(t *testing.T) {
testSMBManual(t, "testuser", "testpass", false)
}
func testSMBManual(t *testing.T, user, pass string, noAuth bool) {
dir := t.TempDir()
err := os.WriteFile(dir+"/hello.txt", []byte("hello world"), 0644)
require.NoError(t, err)
f, err := fs.NewFs(context.Background(), dir)
require.NoError(t, err)
opt := Options{
ListenAddr: "localhost:0",
User: user,
Pass: pass,
NoAuth: noAuth,
ShareName: "rclone",
}
s, err := newServer(context.Background(), f, &opt, &vfscommon.Opt, &proxy.Opt)
require.NoError(t, err)
go func() {
_ = s.Serve()
}()
addr := s.Addr().String()
t.Logf("Server listening on %s", addr)
time.Sleep(200 * time.Millisecond)
clientUser := user
clientPass := pass
if noAuth {
clientUser = "guest"
clientPass = ""
}
d := &smb2client.Dialer{
Initiator: &smb2client.NTLMInitiator{
User: clientUser,
Password: clientPass,
},
}
session, err := d.Dial(context.Background(), addr)
require.NoError(t, err, "SMB dial failed")
share, err := session.Mount("rclone")
require.NoError(t, err, "SMB mount failed")
entries, err := share.ReadDir(".")
require.NoError(t, err, "ReadDir failed")
require.Len(t, entries, 1)
assert.Equal(t, "hello.txt", entries[0].Name())
data, err := share.ReadFile("hello.txt")
require.NoError(t, err, "ReadFile failed")
assert.Equal(t, "hello world", string(data))
err = share.WriteFile("test.txt", []byte("test data"), 0644)
require.NoError(t, err, "WriteFile failed")
_ = share.Umount()
_ = session.Logoff()
assert.NoError(t, s.Shutdown())
}

167
cmd/serve/smb/smb_test.go Normal file
View File

@@ -0,0 +1,167 @@
// Serve smb tests set up a server and run the integration tests
// for the smb remote against it.
//
// We skip tests on platforms with troublesome character mappings
//go:build !windows && !darwin && !plan9
package smb
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
_ "github.com/rclone/rclone/backend/local"
"github.com/rclone/rclone/cmd/serve/proxy"
"github.com/rclone/rclone/cmd/serve/servetest"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/fs/config/obscure"
"github.com/rclone/rclone/fs/rc"
"github.com/rclone/rclone/fstest"
"github.com/rclone/rclone/vfs/vfscommon"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testBindAddress = "localhost:0"
testUser = "testuser"
testPass = "testpass"
)
// startServer creates and starts an SMB server for testing.
// It returns a configmap for the SMB backend to connect and a cleanup function.
func startServer(t *testing.T, f fs.Fs) (configmap.Simple, func()) {
opt := Opt
opt.ListenAddr = testBindAddress
opt.User = testUser
opt.Pass = testPass
opt.ShareName = "rclone"
// Use writes cache mode so that random-access writes (OpenWriterAt) work
vfsOpt := vfscommon.Opt
vfsOpt.CacheMode = vfscommon.CacheModeWrites
w, err := newServer(context.Background(), f, &opt, &vfsOpt, &proxy.Opt)
require.NoError(t, err)
go func() {
require.NoError(t, w.Serve())
}()
// Read the host and port we started on
addr := w.Addr().String()
colon := strings.LastIndex(addr, ":")
// Config for the backend we'll use to connect to the server
config := configmap.Simple{
"type": "smb",
"user": testUser,
"pass": obscure.MustObscure(testPass),
"host": addr[:colon],
"port": addr[colon+1:],
}
return config, func() {
assert.NoError(t, w.Shutdown())
}
}
// TestSmb runs the smb server then runs the unit tests for the
// smb remote against it.
//
// The SMB backend is bucket-based (share = bucket) and requires the
// share name in the remote path. servetest.Run passes "servesmbtest:"
// which has no share name, so we use a custom test runner that passes
// "servesmbtest:rclone" instead.
func TestSmb(t *testing.T) {
fstest.Initialise()
t.Run("Normal", func(t *testing.T) {
runSMBBackendTests(t, false)
})
t.Run("AuthProxy", func(t *testing.T) {
runSMBBackendTests(t, true)
})
}
// runSMBBackendTests starts the server and runs the SMB backend integration tests
// with the share name included in the remote path.
func runSMBBackendTests(t *testing.T, useProxy bool) {
fremote, _, clean, err := fstest.RandomRemote()
assert.NoError(t, err)
defer clean()
err = fremote.Mkdir(context.Background(), "")
assert.NoError(t, err)
f := fremote
if useProxy {
// If using a proxy don't pass in the backend
f = nil
// the backend config will be made by the proxy
prog, err := filepath.Abs("../servetest/proxy_code.go")
require.NoError(t, err)
cmd := "go run " + prog + " " + fremote.Root()
// FIXME this is untidy setting a global variable!
proxy.Opt.AuthProxy = cmd
defer func() {
proxy.Opt.AuthProxy = ""
}()
}
config, cleanup := startServer(t, f)
defer cleanup()
// Change directory to run the tests
cwd, err := os.Getwd()
require.NoError(t, err)
err = os.Chdir("../../../backend/smb")
require.NoError(t, err, "failed to cd to smb backend")
defer func() {
require.NoError(t, os.Chdir(cwd))
}()
// Run the backend tests with the share name in the remote path.
// The SMB backend expects "remoteName:shareName" format.
args := []string{"test"}
if testing.Verbose() {
args = append(args, "-v")
}
if *fstest.Verbose {
args = append(args, "-verbose")
}
remoteName := "servesmbtest:rclone"
args = append(args, "-remote", remoteName)
args = append(args, "-list-retries", fmt.Sprint(*fstest.ListRetries))
cmd := exec.Command("go", args...)
// Configure the backend with environment variables
cmd.Env = os.Environ()
prefix := "RCLONE_CONFIG_SERVESMBTEST_"
for k, v := range config {
cmd.Env = append(cmd.Env, prefix+strings.ToUpper(k)+"="+v)
}
// Run the test
out, err := cmd.CombinedOutput()
if len(out) != 0 {
t.Logf("\n----------\n%s----------\n", string(out))
}
assert.NoError(t, err, "Running smb integration tests")
}
func TestRc(t *testing.T) {
servetest.TestRc(t, rc.Params{
"type": "smb",
"user": "test",
"pass": obscure.MustObscure("test"),
"vfs_cache_mode": "off",
})
}

View File

@@ -0,0 +1,12 @@
// For unsupported platforms (windows, plan9)
//go:build windows || plan9
// Package smb is not supported on Windows or Plan 9
package smb
import (
"github.com/spf13/cobra"
)
// Command is just nil for unsupported platforms
var Command *cobra.Command

571
cmd/serve/smb/vfs.go Normal file
View File

@@ -0,0 +1,571 @@
//go:build !windows && !plan9
// Package smb implements a server to serve a VFS remote over SMB
package smb
import (
"io"
"os"
"path"
"strings"
"sync"
"sync/atomic"
"syscall"
"github.com/macos-fuse-t/go-smb2/vfs"
rclonefs "github.com/rclone/rclone/fs"
rclonevfs "github.com/rclone/rclone/vfs"
)
// smbVFS bridges the macos-fuse-t/go-smb2 VFSFileSystem interface
// to the rclone VFS.
type smbVFS struct {
vfs *rclonevfs.VFS
mu sync.RWMutex
handles map[vfs.VfsHandle]*smbHandle
nextID atomic.Uint64
}
type smbHandle struct {
path string
file rclonevfs.Handle // open file handle (nil for dirs)
dir *rclonevfs.Dir // open dir (nil for files)
node rclonevfs.Node // underlying VFS node
dirPos int // current position in directory listing (for stateful ReadDir)
}
func newSMBVFS(vfsInst *rclonevfs.VFS) *smbVFS {
return &smbVFS{
vfs: vfsInst,
handles: make(map[vfs.VfsHandle]*smbHandle),
}
}
// allocHandle stores a handle and returns its ID
func (s *smbVFS) allocHandle(h *smbHandle) vfs.VfsHandle {
id := vfs.VfsHandle(s.nextID.Add(1))
s.mu.Lock()
s.handles[id] = h
s.mu.Unlock()
return id
}
// getHandle retrieves a handle by ID; handle 0 is the root
func (s *smbVFS) getHandle(id vfs.VfsHandle) *smbHandle {
if id == 0 {
root, err := s.vfs.Root()
if err != nil {
return nil
}
return &smbHandle{
path: "",
dir: root,
node: root,
}
}
s.mu.RLock()
h := s.handles[id]
s.mu.RUnlock()
return h
}
// freeHandle removes a handle from the table
func (s *smbVFS) freeHandle(id vfs.VfsHandle) {
s.mu.Lock()
delete(s.handles, id)
s.mu.Unlock()
}
// cleanPath cleans an SMB path for use with the VFS
func cleanPath(p string) string {
p = strings.ReplaceAll(p, "\\", "/")
p = path.Clean("/" + p)
if p == "/" {
return ""
}
// Remove leading slash - rclone VFS uses relative paths
return p[1:]
}
// nodeToAttrs converts a VFS node to SMB Attributes.
// Node embeds os.FileInfo, so the node itself is the FileInfo.
func nodeToAttrs(node rclonevfs.Node) *vfs.Attributes {
a := &vfs.Attributes{}
a.SetSizeBytes(uint64(node.Size()))
a.SetDiskSizeBytes(uint64(node.Size()))
a.SetLastDataModificationTime(node.ModTime())
a.SetAccessTime(node.ModTime())
a.SetLastStatusChangeTime(node.ModTime())
a.SetBirthTime(node.ModTime())
a.SetLinkCount(1)
if node.IsDir() {
a.SetFileType(vfs.FileTypeDirectory)
a.SetPermissions(vfs.PermissionsRead | vfs.PermissionsWrite | vfs.PermissionsExecute)
} else {
a.SetFileType(vfs.FileTypeRegularFile)
a.SetPermissions(vfs.PermissionsRead | vfs.PermissionsWrite)
}
inode := node.Inode()
a.SetInodeNumber(inode)
a.SetFileHandle(vfs.VfsNode(inode))
a.SetChangeID(uint64(node.ModTime().UnixNano()))
return a
}
// convertError maps common errors to syscall errors that the SMB
// library translates to NT status codes
func convertError(err error) error {
if err == nil {
return nil
}
switch {
case err == rclonevfs.ENOENT || os.IsNotExist(err):
return syscall.ENOENT
case err == rclonevfs.EEXIST || os.IsExist(err):
return syscall.EEXIST
case err == rclonevfs.EPERM || os.IsPermission(err):
return syscall.EACCES
case err == rclonevfs.ENOTEMPTY:
return syscall.ENOTEMPTY
case err == rclonevfs.EINVAL:
return syscall.EINVAL
case err == rclonevfs.ENOSYS:
return syscall.ENOSYS
}
return err
}
// --- VFSFileSystem interface ---
// GetAttr returns attributes for an open handle
func (s *smbVFS) GetAttr(handle vfs.VfsHandle) (*vfs.Attributes, error) {
h := s.getHandle(handle)
if h == nil {
return nil, syscall.EBADF
}
if h.dir != nil {
return nodeToAttrs(h.dir), nil
}
if h.file != nil {
// Get the node from the file handle
node := h.file.Node()
if node != nil {
return nodeToAttrs(node), nil
}
// Fall back to stat by path
n, err := s.vfs.Stat(h.path)
if err != nil {
return nil, convertError(err)
}
return nodeToAttrs(n), nil
}
if h.node != nil {
return nodeToAttrs(h.node), nil
}
return nil, syscall.EBADF
}
// SetAttr sets attributes on an open handle
func (s *smbVFS) SetAttr(handle vfs.VfsHandle, attrs *vfs.Attributes) (*vfs.Attributes, error) {
h := s.getHandle(handle)
if h == nil {
return nil, syscall.EBADF
}
// Handle size change (truncation)
if size, ok := attrs.GetSizeBytes(); ok {
if h.file != nil {
if err := h.file.Truncate(int64(size)); err != nil {
return nil, convertError(err)
}
}
}
// Handle mtime change
if mtime, ok := attrs.GetLastDataModificationTime(); ok {
var node rclonevfs.Node
if h.dir != nil {
node = h.dir
} else if h.node != nil {
node = h.node
} else if h.file != nil {
node = h.file.Node()
}
if node == nil {
n, err := s.vfs.Stat(h.path)
if err == nil {
node = n
}
}
if node != nil {
if err := node.SetModTime(mtime); err != nil {
rclonefs.Debugf(nil, "SetAttr: failed to set mtime on %q: %v", h.path, err)
}
}
}
return s.GetAttr(handle)
}
// StatFS returns filesystem statistics
func (s *smbVFS) StatFS(_ vfs.VfsHandle) (*vfs.FSAttributes, error) {
const blockSize = 4096
total, used, free := s.vfs.Statfs()
fsa := &vfs.FSAttributes{}
fsa.SetBlockSize(blockSize)
fsa.SetIOSize(blockSize)
if total < 0 {
total = 1 << 40 // 1 TiB default
}
if free < 0 {
free = 1 << 40
}
_ = used
fsa.SetBlocks(uint64(total) / blockSize)
fsa.SetFreeBlocks(uint64(free) / blockSize)
fsa.SetAvailableBlocks(uint64(free) / blockSize)
fsa.SetFiles(1000000)
fsa.SetFreeFiles(1000000)
return fsa, nil
}
// FSync syncs an open file handle
func (s *smbVFS) FSync(handle vfs.VfsHandle) error {
h := s.getHandle(handle)
if h == nil {
return syscall.EBADF
}
if h.file != nil {
return convertError(h.file.Sync())
}
return nil
}
// Flush flushes an open file handle
func (s *smbVFS) Flush(handle vfs.VfsHandle) error {
h := s.getHandle(handle)
if h == nil {
return syscall.EBADF
}
if h.file != nil {
return convertError(h.file.Flush())
}
return nil
}
// Open opens a file and returns a handle.
// Falls back to case-insensitive matching for SMB compatibility.
func (s *smbVFS) Open(name string, flags int, mode int) (vfs.VfsHandle, error) {
name = cleanPath(name)
// Strip lock flags that the SMB server adds
flags &^= (oShlock | oExlock | 0x200000)
fh, err := s.vfs.OpenFile(name, flags, os.FileMode(mode))
if err != nil {
// Try case-insensitive lookup and open with the real name
if node, err2 := s.statCaseInsensitive(name); err2 == nil {
realPath := node.Path()
fh, err = s.vfs.OpenFile(realPath, flags, os.FileMode(mode))
if err == nil {
name = realPath
}
}
if err != nil {
return 0, convertError(err)
}
}
h := &smbHandle{
path: name,
file: fh,
node: fh.Node(),
}
return s.allocHandle(h), nil
}
// Close closes an open handle
func (s *smbVFS) Close(handle vfs.VfsHandle) error {
h := s.getHandle(handle)
if h == nil {
return syscall.EBADF
}
s.freeHandle(handle)
if h.file != nil {
return convertError(h.file.Close())
}
return nil
}
// Lookup looks up a child by name in the parent handle's directory.
// When parentHandle is 0, name is treated as a path from the root.
// Lookup is case-insensitive to match Windows SMB behavior.
func (s *smbVFS) Lookup(parentHandle vfs.VfsHandle, name string) (*vfs.Attributes, error) {
var fullPath string
if parentHandle == 0 {
fullPath = cleanPath(name)
} else {
parent := s.getHandle(parentHandle)
if parent == nil {
return nil, syscall.EBADF
}
fullPath = cleanPath(path.Join(parent.path, name))
}
// Try exact match first
node, err := s.vfs.Stat(fullPath)
if err == nil {
return nodeToAttrs(node), nil
}
// Fall back to case-insensitive lookup
node, err = s.statCaseInsensitive(fullPath)
if err != nil {
return nil, convertError(err)
}
return nodeToAttrs(node), nil
}
// statCaseInsensitive does a case-insensitive stat by resolving each
// path component case-insensitively through directory listings.
func (s *smbVFS) statCaseInsensitive(fullPath string) (rclonevfs.Node, error) {
if fullPath == "" {
return s.vfs.Root()
}
parts := strings.Split(fullPath, "/")
root, err := s.vfs.Root()
if err != nil {
return nil, err
}
var current rclonevfs.Node = root
for _, part := range parts {
dir, ok := current.(*rclonevfs.Dir)
if !ok {
return nil, rclonevfs.ENOENT
}
entries, err := dir.ReadDirAll()
if err != nil {
return nil, err
}
found := false
for _, entry := range entries {
if strings.EqualFold(entry.Name(), part) {
current = entry
found = true
break
}
}
if !found {
return nil, rclonevfs.ENOENT
}
}
return current, nil
}
// Mkdir creates a new directory
func (s *smbVFS) Mkdir(name string, _ int) (*vfs.Attributes, error) {
name = cleanPath(name)
dir, leaf := path.Split(name)
dir = strings.TrimSuffix(dir, "/")
parentNode, err := s.vfs.Stat(dir)
if err != nil {
return nil, convertError(err)
}
parentDir, ok := parentNode.(*rclonevfs.Dir)
if !ok {
return nil, syscall.ENOTDIR
}
if _, err := parentDir.Mkdir(leaf); err != nil {
return nil, convertError(err)
}
node, err := s.vfs.Stat(name)
if err != nil {
return nil, convertError(err)
}
return nodeToAttrs(node), nil
}
// Read reads from an open file at the given offset
func (s *smbVFS) Read(handle vfs.VfsHandle, buf []byte, offset uint64, _ int) (int, error) {
h := s.getHandle(handle)
if h == nil || h.file == nil {
return 0, syscall.EBADF
}
n, err := h.file.ReadAt(buf, int64(offset))
if err == io.EOF && n > 0 {
// Partial read with EOF - return data without error
return n, nil
}
return n, convertError(err)
}
// Write writes to an open file at the given offset
func (s *smbVFS) Write(handle vfs.VfsHandle, data []byte, offset uint64, _ int) (int, error) {
h := s.getHandle(handle)
if h == nil || h.file == nil {
return 0, syscall.EBADF
}
n, err := h.file.WriteAt(data, int64(offset))
return n, convertError(err)
}
// OpenDir opens a directory and returns a handle.
// Falls back to case-insensitive matching for SMB compatibility.
func (s *smbVFS) OpenDir(name string) (vfs.VfsHandle, error) {
name = cleanPath(name)
node, err := s.vfs.Stat(name)
if err != nil {
// Try case-insensitive lookup
node, err = s.statCaseInsensitive(name)
if err != nil {
return 0, convertError(err)
}
}
dir, ok := node.(*rclonevfs.Dir)
if !ok {
return 0, syscall.ENOTDIR
}
h := &smbHandle{
path: dir.Path(),
dir: dir,
node: dir,
}
return s.allocHandle(h), nil
}
// ReadDir reads directory entries.
// The pos parameter: 0 = continue from current position, 1 = restart from beginning.
// The count parameter limits the number of entries returned.
// Returns io.EOF when no more entries are available.
func (s *smbVFS) ReadDir(handle vfs.VfsHandle, pos int, count int) ([]vfs.DirInfo, error) {
h := s.getHandle(handle)
if h == nil || h.dir == nil {
return nil, syscall.EBADF
}
// pos=1 means RESTART_SCANS - restart from the beginning
if pos == 1 {
h.dirPos = 0
}
nodes, err := h.dir.ReadDirAll()
if err != nil {
return nil, convertError(err)
}
if h.dirPos >= len(nodes) {
return nil, io.EOF
}
end := h.dirPos + count
if end > len(nodes) {
end = len(nodes)
}
slice := nodes[h.dirPos:end]
h.dirPos = end
entries := make([]vfs.DirInfo, 0, len(slice))
for _, node := range slice {
attrs := nodeToAttrs(node)
entries = append(entries, vfs.DirInfo{
Name: node.Name(),
Attributes: *attrs,
})
}
return entries, nil
}
// Readlink reads the target of a symbolic link (not supported)
func (s *smbVFS) Readlink(_ vfs.VfsHandle) (string, error) {
return "", syscall.ENOSYS
}
// Unlink removes a file or directory by handle
func (s *smbVFS) Unlink(handle vfs.VfsHandle) error {
h := s.getHandle(handle)
if h == nil {
return syscall.EBADF
}
if h.path == "" {
return syscall.EPERM
}
// Look up fresh to get current state
node, err := s.vfs.Stat(h.path)
if err != nil {
return convertError(err)
}
return convertError(node.Remove())
}
// Truncate truncates a file to the given size
func (s *smbVFS) Truncate(handle vfs.VfsHandle, size uint64) error {
h := s.getHandle(handle)
if h == nil || h.file == nil {
return syscall.EBADF
}
return convertError(h.file.Truncate(int64(size)))
}
// Rename renames/moves a file. The newPath parameter is relative to the share root.
func (s *smbVFS) Rename(handle vfs.VfsHandle, newPath string, _ int) error {
h := s.getHandle(handle)
if h == nil {
return syscall.EBADF
}
newPath = cleanPath(newPath)
return convertError(s.vfs.Rename(h.path, newPath))
}
// Symlink creates a symbolic link (not supported)
func (s *smbVFS) Symlink(_ vfs.VfsHandle, _ string, _ int) (*vfs.Attributes, error) {
return nil, syscall.ENOSYS
}
// Link creates a hard link (not supported)
func (s *smbVFS) Link(_ vfs.VfsNode, _ vfs.VfsNode, _ string) (*vfs.Attributes, error) {
return nil, syscall.ENOSYS
}
// Listxattr lists extended attributes (not supported)
func (s *smbVFS) Listxattr(_ vfs.VfsHandle) ([]string, error) {
return nil, nil
}
// Getxattr gets an extended attribute (not supported)
func (s *smbVFS) Getxattr(_ vfs.VfsHandle, _ string, _ []byte) (int, error) {
return 0, syscall.ENOENT
}
// Setxattr sets an extended attribute (not supported)
func (s *smbVFS) Setxattr(_ vfs.VfsHandle, _ string, _ []byte) error {
return syscall.ENOSYS
}
// Removexattr removes an extended attribute (not supported)
func (s *smbVFS) Removexattr(_ vfs.VfsHandle, _ string) error {
return syscall.ENOSYS
}
// oShlock and oExlock are lock flags that the SMB server may add to Open flags
const (
oShlock = 0x10
oExlock = 0x20
)
// Check that smbVFS implements VFSFileSystem
var _ vfs.VFSFileSystem = (*smbVFS)(nil)

1
go.mod
View File

@@ -54,6 +54,7 @@ require (
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6
github.com/lanrat/extsort v1.4.2
github.com/macos-fuse-t/go-smb2 v0.0.0-20260415210427-68ce51f6f833
github.com/mattn/go-colorable v0.1.14
github.com/mattn/go-runewidth v0.0.22
github.com/mholt/archives v0.1.5

2
go.sum
View File

@@ -493,6 +493,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/macos-fuse-t/go-smb2 v0.0.0-20260415210427-68ce51f6f833 h1:HlhbBNR8LXsXBvPXZHypu90uakAb8ulkAdunABeY9ac=
github.com/macos-fuse-t/go-smb2 v0.0.0-20260415210427-68ce51f6f833/go.mod h1:uuaCnBNqDHH0WcjKjxYbZaO65a9rxUk0k1YyTOGP8gs=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=