mirror of
https://github.com/rclone/rclone.git
synced 2026-05-13 19:04:17 -04:00
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:
@@ -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
224
cmd/serve/smb/proxy.go
Normal 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
110
cmd/serve/smb/server.go
Normal 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
157
cmd/serve/smb/smb.go
Normal 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()
|
||||
})
|
||||
},
|
||||
}
|
||||
89
cmd/serve/smb/smb_manual_test.go
Normal file
89
cmd/serve/smb/smb_manual_test.go
Normal 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
167
cmd/serve/smb/smb_test.go
Normal 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",
|
||||
})
|
||||
}
|
||||
12
cmd/serve/smb/smb_unsupported.go
Normal file
12
cmd/serve/smb/smb_unsupported.go
Normal 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
571
cmd/serve/smb/vfs.go
Normal 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
1
go.mod
@@ -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
2
go.sum
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user